├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── LICENSE.txt
├── README.md
├── backend
├── __init__.py
├── convolver_config_import.py
├── eqapo_config_import.py
├── filemanagement.py
├── filters.py
├── legacy_config_import.py
├── routes.py
├── settings.py
├── settings_schemas.py
├── version.py
└── views.py
├── build
└── .put_statics_here
├── config
├── camillagui.yml
└── gui-config.yml
├── main.py
├── release_automation
├── __init__.py
├── render_env_files.py
├── templates
│ ├── cdsp_conda.yml.j2
│ ├── pyproject.toml.j2
│ └── requirements.txt.j2
└── versions.yml
└── tests
├── test_basic_api.py
├── test_convolver_config_import.py
├── test_eqapo_config_import.py
├── test_filters.py
├── test_legacy_config.py
└── testfiles
├── config.yml
├── config2.yml
├── gui_config.yml
├── log.txt
└── statefile_template.yml
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: npm build
2 |
3 | on: [push]
4 |
5 | jobs:
6 |
7 | read_fe_tag:
8 | runs-on: ubuntu-latest
9 | outputs:
10 | fe_tag: ${{ steps.fe_tag_step.outputs.fe_tag }}
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Get CamillaGUI tag from versions.yml
14 | id: fe_tag_step
15 | run: |
16 | FE_TAG=$(sed -n 's/camillagui_tag: \(.*\)$/\1/p' release_automation/versions.yml)
17 | echo "fe_tag=$FE_TAG"
18 | echo "fe_tag=$FE_TAG" >> "$GITHUB_OUTPUT"
19 |
20 | build_fe:
21 | runs-on: ubuntu-latest
22 | needs: read_fe_tag
23 | steps:
24 | - uses: actions/checkout@v4
25 | name: Check out frontend ${{ needs.read_fe_tag.outputs.fe_tag }}
26 | with:
27 | repository: HEnquist/camillagui
28 | ref: ${{ needs.read_fe_tag.outputs.fe_tag }}
29 | - name: Build and publish
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: '20'
33 | - run: npm install
34 | - run: npm run build
35 | - name: Upload build
36 | uses: actions/upload-artifact@v4
37 | with:
38 | name: build
39 | path: build
40 |
41 | build_and_test_be:
42 | runs-on: ubuntu-latest
43 | needs: build_fe
44 | steps:
45 | - uses: actions/checkout@v4
46 | - name: Setup Python
47 | uses: actions/setup-python@v5
48 | with:
49 | python-version: '3.12'
50 |
51 | - name: Install template render dependencies
52 | run: |
53 | python -m pip install --upgrade pip
54 | python -m pip install jinja2 PyYAML
55 |
56 | - name: Render scripts from templates
57 | run: python -Bm release_automation.render_env_files
58 |
59 | - name: Install requirements
60 | run: python -m pip install -r requirements.txt
61 |
62 | - name: Set up pytest
63 | run: python -m pip install pytest-aiohttp
64 |
65 | - name: Run python tests
66 | run: python -Bm pytest
67 |
68 | - name: Clean up
69 | run: |
70 | rm -rf release_automation
71 | rm -rf tests
72 |
73 | - name: Download frontend
74 | uses: actions/download-artifact@v4
75 |
76 | - name: Create zip
77 | run: zip -r camillagui.zip *
78 |
79 | - name: Upload all as artifact
80 | uses: actions/upload-artifact@v4
81 | with:
82 | name: camillagui-backend
83 | path: |
84 | .
85 | !.git*
86 |
87 | - name: Upload binaries to release
88 | if: contains(github.ref, 'refs/tags/')
89 | uses: svenstaro/upload-release-action@v2
90 | with:
91 | repo_token: ${{ secrets.GITHUB_TOKEN }}
92 | file: camillagui.zip
93 | asset_name: camillagui.zip
94 | tag: ${{ github.ref }}
95 |
96 | pyinstaller_native:
97 | name: Bundle for ${{ matrix.os }}
98 | runs-on: ${{ matrix.os }}
99 | strategy:
100 | matrix:
101 | include:
102 | - os: ubuntu-22.04
103 | asset_name: bundle_linux_amd64.tar.gz
104 | filename: bundle.tar.gz
105 | - os: ubuntu-22.04-arm
106 | asset_name: bundle_linux_aarch64.tar.gz
107 | filename: bundle.tar.gz
108 | - os: windows-latest
109 | asset_name: bundle_windows_amd64.zip
110 | filename: bundle.zip
111 | - os: macos-latest
112 | asset_name: bundle_macos_aarch64.tar.gz
113 | filename: bundle.tar.gz
114 | - os: macos-13
115 | asset_name: bundle_macos_intel.tar.gz
116 | filename: bundle.tar.gz
117 | needs: build_and_test_be
118 | steps:
119 | - name: Download complete distribution
120 | uses: actions/download-artifact@v4
121 | with:
122 | name: camillagui-backend
123 |
124 | - uses: actions/setup-python@v5
125 | with:
126 | python-version: '3.12'
127 |
128 | - run: pip install -r requirements.txt
129 | name: Install backend dependencies
130 |
131 | - run: pip install pyinstaller
132 | name: Install pyinstaller
133 |
134 | - name: Bundle app with pyinstaller
135 | run: pyinstaller ./main.py --add-data ./config/:config --add-data ./build/:build --collect-data camilladsp_plot --name camillagui_backend
136 |
137 | - name: Compress as zip
138 | if: ${{ contains(matrix.os, 'windows') }}
139 | run: powershell Compress-Archive ./dist/camillagui_backend ${{ matrix.filename }}
140 | - name: Compress as tar.gz
141 | if: ${{ contains(matrix.os, 'macos') }}
142 | run: tar -zcvf ${{ matrix.filename }} -C ./dist camillagui_backend
143 | - name: Compress as tar.gz
144 | if: ${{ contains(matrix.os, 'ubuntu') }}
145 | run: tar -zcvf ${{ matrix.filename }} -C ./dist camillagui_backend --owner=0 --group=0 --numeric-owner
146 |
147 | - name: Upload bundle as artifact
148 | uses: actions/upload-artifact@v4
149 | with:
150 | name: ${{ matrix.asset_name }}
151 | path: ${{ matrix.filename }}
152 |
153 | - name: Upload bundle to release
154 | if: contains(github.ref, 'refs/tags/')
155 | uses: svenstaro/upload-release-action@v2
156 | with:
157 | repo_token: ${{ secrets.GITHUB_TOKEN }}
158 | file: ${{ matrix.filename }}
159 | asset_name: ${{ matrix.asset_name }}
160 | tag: ${{ github.ref }}
161 |
162 | pyinstaller_qemu:
163 | runs-on: ubuntu-22.04
164 | needs: build_and_test_be
165 | name: Bundle for ${{ matrix.arch }}
166 |
167 | strategy:
168 | matrix:
169 | include:
170 | - arch: armv7
171 | - arch: armv6
172 | steps:
173 | - name: Download complete distribution
174 | uses: actions/download-artifact@v4
175 | with:
176 | name: camillagui-backend
177 | - uses: uraimo/run-on-arch-action@v2
178 | name: Build artifact
179 | id: build
180 | with:
181 | arch: ${{ matrix.arch }}
182 | distro: bookworm
183 |
184 | # Mount the artifacts directory as /artifacts in the container
185 | dockerRunArgs: |
186 | --volume "${PWD}:/cdsp"
187 | install: |
188 | apt update -y
189 | apt install git python3 python3-pip python3-venv python3-dev curl make gcc build-essential binutils pkg-config -y
190 | run: |
191 | python3 -m venv ./venv
192 | ./venv/bin/python3 -m pip config set global.extra-index-url https://www.piwheels.org/simple
193 | ./venv/bin/python3 -m pip install -r requirements.txt
194 | ./venv/bin/python3 -m pip install pyinstaller
195 | ./venv/bin/python3 -m PyInstaller /cdsp/main.py --distpath /cdsp/dist --add-data ./config/:config --add-data ./build/:build --collect-data camilladsp_plot --name camillagui_backend
196 |
197 | - name: Compress as tar.gz
198 | run: tar -zcvf bundle.tar.gz -C ./dist camillagui_backend --owner=0 --group=0 --numeric-owner
199 |
200 | - name: Upload bundle as artifact
201 | uses: actions/upload-artifact@v4
202 | with:
203 | name: bundle_linux_${{ matrix.arch }}
204 | path: bundle.tar.gz
205 |
206 | - name: Upload bundle to release
207 | if: contains(github.ref, 'refs/tags/')
208 | uses: svenstaro/upload-release-action@v2
209 | with:
210 | repo_token: ${{ secrets.GITHUB_TOKEN }}
211 | file: bundle.tar.gz
212 | asset_name: bundle_linux_${{ matrix.arch }}.tar.gz
213 | tag: ${{ github.ref }}
214 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | build/*
3 | .venv
4 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Backend server for CamillaGUI
2 |
3 | This is the server part of CamillaGUI, a web-based GUI for CamillaDSP.
4 |
5 | This version works with CamillaDSP 3.0.x.
6 |
7 | The complete GUI is made up of two parts:
8 | - a frontend based on React: https://reactjs.org/
9 | - a backend based on AIOHTTP: https://docs.aiohttp.org/en/stable/
10 |
11 | ## Download a complete bundle
12 | The easiest way to run the gui is to download and run one of the published bundles.
13 | These contain the gui backend server, the frontend files,
14 | and a complete Python environment.
15 | Bundles are provided for most common systems and cpu architectures.
16 |
17 | ### Downloading the bundled gui
18 | Go to "Releases": https://github.com/HEnquist/camillagui-backend/releases
19 | Download the bundle for you system, for example "bundle_linux_amd64.tar.gz"
20 | for a linux system running an AMD or Intel cpu.
21 | Uncompress the archive to a directory of your choice.
22 | A suggestion is to create a directory named `camilladsp`
23 | in you home directory, and place the `camillagui_backend` in it.
24 | Also create directories named `configs` and `coeffs` in the `camilladsp` directory.
25 |
26 | ### Configureing the bundled gui
27 | The gui configuration is stored in the bundle,
28 | at `camillagui_backend/_internal/config/camillagui.yml`.
29 | See [Configuration](#configuration) for an explanation of the options.
30 | The default confuguration uses the `configs` and `coeffs` directories
31 | created in the previous step, but these locations can be changed by
32 | editing the configuration file.
33 |
34 | ### Running the bundled gui
35 | The archive contains a directory called `camillagui_backend`.
36 | Inside this directory there is an executable named `camillagui_backend`
37 | (or `camillagui_backend.exe` on windows).
38 | Run this executable to start the gui backend.
39 |
40 | ## Setting up a in a Python environment
41 | This option sets up the gui backend in a Python environment.
42 | This gives more flexibility to customize the system,
43 | for example to develop Python scrips that use the pycamilladsp library.
44 |
45 | ### Download the gui server
46 | Go to "Releases": https://github.com/HEnquist/camillagui-backend/releases
47 | Download the zip-file ("camillagui.zip") for the latest release. This includes both the backend and the frontend.
48 |
49 | Unzip the file and edit `config/camillagui.yml` as needed, see [Configuration](#configuration).
50 |
51 | ### Python dependencies
52 | The Python dependencies are listed in three different files,
53 | for use with different Python package/environment managers.
54 | - `cdsp_conda.yml` for [conda](https://conda.io/).
55 | - `requirements.txt` for [pip](https://pip.pypa.io/), often combined with an environment manager
56 | such as [venv](https://docs.python.org/3/library/venv.html).
57 | - `pyproject.toml` for [Poetry](https://python-poetry.org).
58 |
59 |
60 | ### Prepare the Python environment
61 | The easiest way to get the Python environment prepared is to use the setup scripts from
62 | [camilladsp-setupscripts](https://github.com/HEnquist/camilladsp-setupscripts).
63 |
64 | If doing a manual installation, there are many ways of installing Python and setting up environments.
65 | Please see the [documentation for pycamilladsp](https://henquist.github.io/pycamilladsp/install/#installing)
66 | for more information.
67 |
68 |
69 | ## Configuration
70 |
71 | The backend configuration is stored in `config/camillagui.yml` by default.
72 |
73 | ```yaml
74 | ---
75 | camilla_host: "0.0.0.0"
76 | camilla_port: 1234
77 | bind_address: "0.0.0.0"
78 | port: 5005
79 | ssl_certificate: null (*)
80 | ssl_private_key: null (*)
81 | gui_config_file: null (*)
82 | config_dir: "~/camilladsp/configs"
83 | coeff_dir: "~/camilladsp/coeffs"
84 | default_config: "~/camilladsp/default_config.yml"
85 | statefile_path: "~/camilladsp/statefile.yml"
86 | log_file: "~/camilladsp/camilladsp.log" (*, defaults to null)
87 | on_set_active_config: null (*)
88 | on_get_active_config: null (*)
89 | supported_capture_types: null (*)
90 | supported_playback_types: null (*)
91 | ```
92 | The options marked `(*)` are optional. If left out the default values listed above will be used.
93 | The included configuration has CamillaDSP running on the same machine as the backend,
94 | with the websocket server enabled at port 1234.
95 | The web interface will be served on port 5005 using plain HTTP.
96 | It is possible to run the gui and CamillaDSP on different machines,
97 | just point the `camilla_host` to the right address.
98 |
99 | The optional `gui_config_file` can be used to override the default path to the gui config file.
100 |
101 | **Warning**: By default the backend will bind to all network interfaces.
102 | This makes the gui available on all networks the system is connected to, which may be insecure.
103 | Make sure to change the `bind_address` if you want it to be reachable only on specific
104 | network interface(s) and/or to set your firewall to block external (internet) access to this backend.
105 |
106 | The `ssl_certificate` and `ssl_private_key` options are used to configure SSL, to enable HTTPS.
107 | Both a certificate and a private key are required.
108 | The values for `ssl_certificate` and `ssl_private_key` should then be
109 | the paths to the files containing the certificate and key.
110 | It's also possible to keep both certificate and key in a single file.
111 | In that case, provide only `ssl_certificate`.
112 | See the [Python ssl documentation](https://docs.python.org/3/library/ssl.html#ssl-certificates)
113 | for more info on certificates.
114 |
115 | To generate a self-signed certificate and key pair, use openssl:
116 | ```sh
117 | openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout my_private_key.key -out my_certificate.crt
118 | ```
119 |
120 | The settings for config_dir and coeff_dir point to two folders where the backend has permissions to write files.
121 | This is provided to enable uploading of coefficients and config files from the gui.
122 |
123 | If you want to be able to view the log file in the GUI, configure CamillaDSP to log to `log_file`.
124 |
125 | ### Active config file
126 | The active config file path is memorized via the CamillaDSP state file.
127 | Set the `statefile_path` to point at the statefile that the CamillaDSP process uses.
128 | For this to work, CamillaDSP must be running with a statefile.
129 | That is achieved by starting it with the `-s` parameter,
130 | giving the same path to the statefile as in `camillagui.yml`:
131 | ```sh
132 | camilladsp -p 1234 -w -s /path/to/statefile.yml
133 | ```
134 |
135 | If the CamillaDSP process is running, the active config file path
136 | will be fetched by querying the running process.
137 | If its not running, it will instead be read directly from the statefile.
138 |
139 | The active config will be loaded into the web interface when it is opened.
140 | If there is no active config, the `default_config` will be used.
141 | If this does not exist, the internal default config is used.
142 | Note: the active config will NOT be automatically applied to CamillaDSP, when the GUI starts.
143 |
144 | See also [Integrating with other software](#integrating-with-other-software)
145 |
146 |
147 | ### Limit device types
148 | By default, the config validator allows all the device types that CamillaDSP can support.
149 | To limit this to the types that are supported on a particular system, give the list of supported types as:
150 | ```yaml
151 | supported_capture_types: ["Alsa", "File", "Stdin"]
152 | supported_playback_types: ["Alsa", "File", "Stdout"]
153 | ```
154 |
155 | ### Integrating with other software
156 | If you want to integrate CamillaGUI with other software,
157 | there are some options to customize the UI for your particular needs.
158 |
159 | #### Setting and getting the active config
160 | _NOTE: This functionality is experimental, there may be significant changes in future versions._
161 |
162 | The configuration options `on_set_active_config` and `on_get_active_config` can be used to customize
163 | the way the active config file path is stored.
164 | These are shell commands that will be run to set and get the active config.
165 | Setting these options will override the normal way of getting and setting the active config path.
166 | Since the commands are run in the operating system shell, the syntax depends on which operating system is used.
167 | The examples given below are for Linux.
168 |
169 | The `on_set_active_config` uses Python string formatting to insert the filename.
170 | This means it must contain an empty set of curly brackets, where the filename will get inserted surrounded by quotes.
171 |
172 | Examples:
173 | - Running a script: `on_set_active_config: my_updater_script.sh {}`
174 |
175 | The backend will run the command: `my_updater_script.sh "/full/path/to/new_active_config.yml"`
176 | - Saving config filename to a text file: `on_set_active_config: echo {} > active_configname.txt`
177 |
178 | The backend will run the command: `echo "/full/path/to/new_active_config.yml" > active_configname.txt`
179 |
180 | The `on_get_active_config` command is expected to return a filename on stdout.
181 | As an example, read a filename from a text file: `on_get_active_config: "cat myconfig.txt"`.
182 |
183 |
184 |
185 | ## Customizing the GUI
186 | Some functionality of the GUI can be customized by editing `config/gui-config.yml`.
187 | The styling can be customized by editing `build/css-variables.css`.
188 |
189 | ### Adding custom shortcut settings
190 | It is possible to configure custom shortcuts for the `Shortcuts` section and the compact view.
191 | The included config file contains the default Bass and Treble filters,
192 | as well as a few commented out examples.
193 |
194 | To add more, edit the file `config/gui-config.yml` to add
195 | the new shortcuts to the list under `custom_shortcuts`.
196 |
197 | Here is an example config to set the gain of the filters called `MyFilter` and `MyOtherFilter`.
198 | within the range from -10 to 0 db in steps of 0.1 dB.
199 | For `MyOtherFilter`, the scale is reversed, such that moving the slider from -10 to -9 dB
200 | changes the gain of `MyOtherFilter` fom 0 to -1 dB.
201 | The `type` property is set to `number`.
202 | This creates a slider control, used to control numerical values.
203 | It can also be set to `boolean` which creates a checkbox.
204 | For `number`, the `range_from`, `range_to` and `step` properties are required.
205 | They are not used by `boolean` controls and may be left out.
206 |
207 | ```yaml
208 | custom_shortcuts:
209 | - section: "My custom section"
210 | description: |
211 | Optional description for the section.
212 | Omit this attribute, if unwanted.
213 | The text will be shown in the gui with line breaks.
214 | shortcuts:
215 | - name: "My filter gain"
216 | description: |
217 | Optional description for the setting.
218 | Omit this attribute, if unwanted.
219 | config_elements:
220 | - path: ["filters", "MyFilter", "parameters", "gain"]
221 | reverse: false
222 | - path: ["filters", "MyOtherFilter", "parameters", "gain"]
223 | reverse: true
224 | range_from: -10
225 | range_to: 0
226 | step: 0.1
227 | type: "number"
228 | ```
229 | When letting a shortcut control more than one element in the config,
230 | the first one is considered the main one, that controls the slider position.
231 | The first element must be present in the config in order for the shortcut to function.
232 |
233 | If any of the others is not at the expected value, the GUI will show a warning.
234 | The same happens if any of the others is missing in the config.
235 | The control can then still be used, but may not give the wanted result.
236 |
237 | ### Hiding GUI Options
238 | Options can be hidden from your users by editing `config/gui-config.yml`.
239 | Setting any of the options to `true` hides the corresponding option or section.
240 | These are all optional, and default to `false` if left out.
241 | ```yaml
242 | hide_capture_samplerate: false
243 | hide_silence: false
244 | hide_capture_device: false
245 | hide_playback_device: false
246 | hide_rate_monitoring: false
247 | hide_multithreading: false
248 | ```
249 |
250 | ### Styling the GUI
251 | The UI can be styled by editing `build/css-variables.css`.
252 | Further instructions on how to do this, or switch back to the brighter black/white UI, can be found there.
253 |
254 | ### Other GUI Options
255 | Changes to the currently edited config can be applied automatically, but this behavior is disabled by default.
256 | To enable it by default, in `config/gui-config.yml` set `apply_config_automatically` to `true`.
257 |
258 | The update rate of the level meters can be adjusted by changing the `status_update_interval` setting.
259 | The value is in milliseconds, and the default value is 100 ms.
260 |
261 | ### Gui config syntax check
262 | The gui config is checked when the backend starts, and any problems are logged.
263 | For example, the `range_from` property of a config shortcut must be a number.
264 | If it is not, this results in a message such as this:
265 | ```
266 | ERROR:root:Parameter 'custom_shortcuts/0/shortcuts/1/range_from': 'hello' is not of type 'number'
267 | ```
268 |
269 | ## Running
270 | If using the bundle, start the server by changing to the directory
271 | containing the executable and run running it.
272 | Linux and macOS:
273 | ```sh
274 | ./camillagui_backend
275 | ```
276 | ```sh
277 | camillagui_backend.exe
278 | ```
279 | On windows it is also possible to start by double-clicking the .exe-file.
280 |
281 | For a Python environment, the command for starting the server is:
282 | ```sh
283 | python main.py
284 | ```
285 |
286 | All methods of starting the server accept the same command line arguments,
287 | and running with `--help` shows the available arguments.
288 |
289 | The gui should now be available at: http://localhost:5005/gui/index.html
290 |
291 | If accessing the gui from a different machine, replace "localhost" by the IP
292 | or hostname of the machine running the gui server.
293 |
294 | ### Command line options
295 | The logging level for the backend itself as well as the AIOHTTP framework are set to `WARNING` by default.
296 | These can both be changed with command line arguments, which may be useful when debugging some problem.
297 |
298 | The backend norally reads its configuration from a default location.
299 | This can be changed by providing a different path as a command line argument.
300 |
301 | Use the `-h` or `--help` argument to view the built-in help:
302 | ```
303 | > python main.py --help
304 | usage: python main.py [-h] [-c CONFIG] [-l {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}]
305 | [-a {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}]
306 |
307 | Backend for the CamillaDSP web GUI
308 |
309 | options:
310 | -h, --help show this help message and exit
311 | -c CONFIG, --config CONFIG
312 | Provide a path to a backend config file to use instead of the default
313 | -l {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}, --log-level {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}
314 | Logging level
315 | -a {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}, --aiohttp-log-level {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET}
316 | AIOHTTP logging level
317 | ```
318 |
319 |
320 | ## Development
321 | ### Render the environment files
322 | This repository contains [jinja](https://palletsprojects.com/p/jinja/)
323 | templates used to create the Python environment files.
324 | The templates are stored in `release_automation/templates/`.
325 |
326 | To render the templates, install the dependencies `PyYAML` and `jinja2`
327 | and run the Python script `render_env_files.py`:
328 | ```sh
329 | python -m release_automation.render_env_files
330 | ```
331 | When rendering, the versions of the Python dependencies are taken
332 | from the file `release_automation/versions.yml`.
333 | The backend version is read from `backend/version.py`.
334 |
335 | ### Running the tests
336 | Install the pytest plugin `pytest-aiohttp`.
337 |
338 | Execute the tests with:
339 |
340 | ```sh
341 | python -m pytest
342 | ```
343 |
--------------------------------------------------------------------------------
/backend/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HEnquist/camillagui-backend/0e99986a7558cc64b5ed3ff47ef7fd698eeeaa98/backend/__init__.py
--------------------------------------------------------------------------------
/backend/convolver_config_import.py:
--------------------------------------------------------------------------------
1 | from os.path import basename
2 | from typing import List, Tuple
3 |
4 |
5 | def filename_of_path(path: str) -> str:
6 | """
7 | Return just the filename from a full path.
8 | Accepts both Windows paths such as C:\temp\file.wav
9 | and Unix paths such as /tmp/file.wav
10 | """
11 | return basename(path.replace("\\", "/"))
12 |
13 |
14 | def fraction_to_gain(fraction: str) -> float:
15 | if int(fraction) == 0:
16 | # Special case, n.0 means channel n with a linear gain of 1.0
17 | return 1.0
18 | # n.mmm means channel n with a linear gain of 0.mmm
19 | return float(f"0.{fraction}")
20 |
21 |
22 | def parse_channel_and_fraction(channel: str, fraction: str) -> Tuple[int, float, bool]:
23 | int_channel = abs(int(channel))
24 | gain = fraction_to_gain(fraction)
25 | inverted = channel.startswith("-")
26 | return (abs(int_channel), gain, inverted)
27 |
28 |
29 | def channels_factors_and_inversions_as_list(
30 | channels_and_factors: str,
31 | ) -> List[Tuple[int, float, bool]]:
32 | channels_and_fractions = [
33 | channel_and_fraction.split(".")
34 | for channel_and_fraction in channels_and_factors.split(" ")
35 | ]
36 | return [
37 | parse_channel_and_fraction(channel, fraction)
38 | for (channel, fraction) in channels_and_fractions
39 | ]
40 |
41 |
42 | def make_filter_step(channels: List[int], names: List[str]) -> dict:
43 | return {
44 | "type": "Filter",
45 | "channels": channels,
46 | "names": names,
47 | "bypassed": None,
48 | "description": None,
49 | }
50 |
51 |
52 | def make_mixer_mapping(input_channels: List[tuple], output_channel: int) -> dict:
53 | return {
54 | "dest": output_channel,
55 | "sources": [
56 | {
57 | "channel": channel,
58 | "gain": factor,
59 | "scale": "linear",
60 | "inverted": invert,
61 | }
62 | for (channel, factor, invert) in input_channels
63 | ],
64 | }
65 |
66 |
67 | class Filter:
68 | filename: str
69 | channel: int
70 | channel_in_file: int
71 | input_channels: List[Tuple[int, float, bool]]
72 | output_channels: List[Tuple[int, float, bool]]
73 |
74 | def __init__(self, channel, filter_text: List[str]):
75 | self.channel = channel
76 | self.filename = filename_of_path(filter_text[0])
77 | self.channel_in_file = int(filter_text[1])
78 | self.input_channels = channels_factors_and_inversions_as_list(filter_text[2])
79 | self.output_channels = channels_factors_and_inversions_as_list(filter_text[3])
80 |
81 | def name(self) -> str:
82 | return self.filename + "-" + str(self.channel_in_file)
83 |
84 |
85 | class ConvolverConfig:
86 | _samplerate: int
87 | _input_channels: int
88 | _output_channels: int
89 | _input_delays: List[int]
90 | _output_delays: List[int]
91 | _filters: List[Filter]
92 |
93 | def __init__(self, config_text: str):
94 | """
95 | :param config_text: a convolver config (https://convolver.sourceforge.net/config.html) as string
96 | """
97 | lines = config_text.splitlines()
98 | first_line_items = lines[0].split()
99 | self._samplerate = int(first_line_items[0])
100 | self._input_channels = int(first_line_items[1])
101 | self._output_channels = int(first_line_items[2])
102 | self._input_delays = [int(x) for x in lines[1].split()]
103 | self._output_delays = [int(x) for x in lines[2].split()]
104 | filter_lines = lines[3 : len(lines)]
105 | filter_count = int(len(filter_lines) / 4)
106 | self._filters = [
107 | Filter(n, filter_lines[n * 4 : n * 4 + 4]) for n in range(filter_count)
108 | ]
109 |
110 | def to_object(self) -> dict:
111 | filters = self._delay_filter_definitions()
112 | filters.update(self._convolution_filter_definitions())
113 | mixers = self._mixer_in()
114 | mixers.update(self._mixer_out())
115 | return {
116 | "devices": {"samplerate": self._samplerate},
117 | "filters": filters,
118 | "mixers": mixers,
119 | "pipeline": self._input_delay_pipeline_steps()
120 | + self._mixer_in_pipeline_step()
121 | + self._filter_pipeline_steps()
122 | + self._mixer_out_pipeline_step()
123 | + self._output_delay_pipeline_steps(),
124 | }
125 |
126 | def _delay_filter_definitions(self) -> dict:
127 | delays = set(self._input_delays + self._output_delays)
128 | delays.remove(0)
129 | return {self._delay_name(delay): self._delay_filter(delay) for delay in delays}
130 |
131 | @staticmethod
132 | def _delay_name(delay: int) -> str:
133 | return "Delay" + str(delay)
134 |
135 | @staticmethod
136 | def _delay_filter(delay: int) -> dict:
137 | return {
138 | "type": "Delay",
139 | "parameters": {"delay": delay, "unit": "ms", "subsample": False},
140 | }
141 |
142 | def _convolution_filter_definitions(self) -> dict:
143 | return {
144 | f.name(): {
145 | "type": "Conv",
146 | "parameters": {
147 | "type": "Wav",
148 | "filename": f.filename,
149 | "channel": f.channel_in_file,
150 | },
151 | }
152 | for f in self._filters
153 | }
154 |
155 | def _input_delay_pipeline_steps(self) -> List[dict]:
156 | return self._delay_pipeline_steps(self._input_delays)
157 |
158 | def _delay_pipeline_steps(self, delays: List[int]) -> List[dict]:
159 | return [
160 | make_filter_step([channel], [self._delay_name(delay)])
161 | for channel, delay in enumerate(delays)
162 | if delay != 0
163 | ]
164 |
165 | def _output_delay_pipeline_steps(self) -> List[dict]:
166 | return self._delay_pipeline_steps(self._output_delays)
167 |
168 | def _mixer_in(self) -> dict:
169 | return {
170 | "Mixer in": {
171 | "channels": {
172 | "in": self._input_channels,
173 | "out": max(1, len(self._filters)),
174 | },
175 | "mapping": [
176 | make_mixer_mapping(f.input_channels, f.channel)
177 | for f in self._filters
178 | ],
179 | }
180 | }
181 |
182 | def _mixer_out(self) -> dict:
183 | return {
184 | "Mixer out": {
185 | "channels": {
186 | "in": max(1, len(self._filters)),
187 | "out": self._output_channels,
188 | },
189 | "mapping": [
190 | make_mixer_mapping(
191 | [
192 | (f.channel, factor, invert)
193 | for f in self._filters
194 | for (channel, factor, invert) in f.output_channels
195 | if channel == output_channel
196 | ],
197 | output_channel,
198 | )
199 | for output_channel in range(self._output_channels)
200 | ],
201 | }
202 | }
203 |
204 | @staticmethod
205 | def _mixer_in_pipeline_step() -> List[dict]:
206 | return [{"type": "Mixer", "name": "Mixer in", "description": None}]
207 |
208 | @staticmethod
209 | def _mixer_out_pipeline_step() -> List[dict]:
210 | return [{"type": "Mixer", "name": "Mixer out", "description": None}]
211 |
212 | def _filter_pipeline_steps(self) -> List[dict]:
213 | return [make_filter_step([f.channel], [f.name()]) for f in self._filters]
214 |
--------------------------------------------------------------------------------
/backend/eqapo_config_import.py:
--------------------------------------------------------------------------------
1 | from copy import copy, deepcopy
2 | import logging
3 |
4 |
5 | class EqAPO:
6 | filter_types = {
7 | "PK": "Peaking",
8 | "PEQ": "Peaking",
9 | "HP": "Highpass",
10 | "HPQ": "Highpass",
11 | "LP": "Lowpass",
12 | "LPQ": "Lowpass",
13 | "BP": "Bandpass",
14 | "NO": "Notch",
15 | "LS": "Lowshelf",
16 | "LSC": "Lowshelf",
17 | "HS": "Highshelf",
18 | "HSC": "Highshelf",
19 | "IIR": None, # TODO
20 | }
21 | # TODO
22 | # add support for
23 | # HSC x dB: High-shelf filter x dB per oct.
24 | # LSC x dB: Low-shelf filter x dB per oct.
25 | # LS 6dB: Low-shelf filter 6 dB per octave, with corner freq.
26 | # LS 12dB: Low-shelf filter 12 dB per octave, with corner freq.
27 | # HS 6dB: High-shelf filter 6 dB per octave, with corner freq.
28 | # HS 12dB: High-shelf filter 12 dB per octave, with corner freq.
29 |
30 | # Label to channel number
31 | all_channel_maps = {
32 | 1: {"C": 1},
33 | 2: {"L": 0, "R": 1},
34 | 4: {"L": 0, "R": 1, "RL": 2, "RR": 3},
35 | 6: {"L": 0, "R": 1, "C": 2, "LFE": 3, "RL": 4, "RR": 5},
36 | 8: {"L": 0, "R": 1, "C": 2, "LFE": 3, "RL": 4, "RR": 5, "SL": 6, "SR": 7},
37 | }
38 |
39 | delay_units = {"ms": "ms", "samples": "samples"}
40 |
41 | def __init__(self, config_text, nbr_channels):
42 | self.lines = config_text.splitlines()
43 | self.filters = {}
44 | self.mixers = {}
45 | self.name_index = {
46 | "Filter": 1,
47 | "Preamp": 1,
48 | "Convolution": 1,
49 | "Delay": 1,
50 | "Copy": 1,
51 | }
52 | self.selected_channels = None
53 | self.current_filterstep = {
54 | "type": "Filter",
55 | "names": [],
56 | "description": "Default, all channels",
57 | "channels": copy(self.selected_channels),
58 | }
59 | self.pipeline = [self.current_filterstep]
60 | self.nbr_channels = nbr_channels
61 | self.channel_map = self.all_channel_maps.get(
62 | nbr_channels, self.all_channel_maps[8]
63 | )
64 |
65 | def lookup_channel_index(self, label):
66 | if label in self.channel_map:
67 | channel = self.channel_map[label]
68 | elif label.isnumeric():
69 | channel = int(label) - 1
70 | else:
71 | logging.warning(
72 | f"Virtual channels are not supported, skipping channel {label}"
73 | )
74 | channel = None
75 | return channel
76 |
77 | def parse_number(self, value_str):
78 | try:
79 | return float(value_str)
80 | except ValueError:
81 | logging.warning(
82 | f"Unable to parse '{value_str}' as number, inline expressions are not supported."
83 | )
84 | return None
85 |
86 | # Parse a single command parameter
87 | def parse_single_parameter(self, params):
88 | # Inline expressions (ex: Fc `2*a`) are not supported
89 | # TODO add a check for this.
90 | if params[0] == "Fc":
91 | nbr_tokens = 3
92 | assert params[2].lower() == "hz"
93 | value = self.parse_number(params[1])
94 | parsed = {"freq": value}
95 | elif params[0] == "Q":
96 | nbr_tokens = 2
97 | value = self.parse_number(params[1])
98 | parsed = {"q": value}
99 | elif params[0] == "Gain":
100 | nbr_tokens = 3
101 | assert params[2].lower() == "db"
102 | value = self.parse_number(params[1])
103 | parsed = {"gain": value}
104 | elif params[0] == "BW":
105 | nbr_tokens = 3
106 | assert params[1].lower() == "oct"
107 | value = self.parse_number(params[2])
108 | parsed = {"bandwidth": value}
109 | else:
110 | logging.warning("Skipping unknown token:", params[0])
111 | return {}, params[1:]
112 | return parsed, params[nbr_tokens:]
113 |
114 | # Parse the parameters for a command
115 | def parse_filter_params(self, param_str):
116 | params = param_str.split()
117 | enabled = params[0] == "ON"
118 | ftype = params[1]
119 | ftype_c = self.filter_types.get(ftype)
120 | if not ftype_c:
121 | logging.warning(f"Unsupported filter type '{ftype}'")
122 | return None
123 | param_dict = {"type": ftype_c}
124 | tokens = params[2:]
125 | while tokens:
126 | p, tokens = self.parse_single_parameter(tokens)
127 | param_dict.update(p)
128 | return param_dict
129 |
130 | # Parse a Preamp command to a filter
131 | def parse_gain(self, param_str):
132 | params = param_str.split()
133 | gain = self.parse_number(params[0])
134 | if params[1].lower() != "db":
135 | logging.warning("invalid preamp line:", param_str)
136 | return
137 | return {"type": "Gain", "parameters": {"gain": gain, "scale": "dB"}}
138 |
139 | # Parse a Delay command to a filter
140 | def parse_delay(self, param_str):
141 | params = param_str.split()
142 | delay = self.parse_number(params[0])
143 | unit = self.delay_units[params[1]]
144 | return {"type": "Delay", "parameters": {"delay": delay, "unit": unit}}
145 |
146 | # Parse a Copy command into a Mixer
147 | def parse_copy(self, param_str):
148 | handled_channels = set()
149 | mixer = {
150 | "channels": {
151 | "in": self.nbr_channels,
152 | "out": self.nbr_channels,
153 | },
154 | "mapping": [],
155 | }
156 | params = param_str.strip().split(" ")
157 | for dest in params:
158 | dest_ch, expr = dest.split("=")
159 | dest_ch = self.lookup_channel_index(dest_ch)
160 | handled_channels.add(dest_ch)
161 | logging.debug("dest", dest_ch)
162 | mapping = {"dest": dest_ch, "mute": False, "sources": []}
163 | mixer["mapping"].append(mapping)
164 | sources = expr.split("+")
165 | for source in sources:
166 | if "*" in source:
167 | gain_str, channel = source.split("*")
168 | if gain_str.endswith("dB"):
169 | gain = self.parse_number(gain_str[:-2])
170 | scale = "dB"
171 | else:
172 | gain = self.parse_number(gain_str)
173 | scale = "linear"
174 | elif source == "0.0":
175 | # EqAPO supports setting channels to an arbitrary constant.
176 | # Here only 0.0 is supported, as other values have no practical use.
177 | channel = None
178 | else:
179 | gain = 0
180 | scale = "dB"
181 | channel = source
182 | if channel is not None:
183 | channel = self.lookup_channel_index(channel)
184 | # TODO make a mixer config
185 | logging.debug("source", channel, gain, scale)
186 | source = {
187 | "channel": channel,
188 | "gain": gain,
189 | "inverted": False,
190 | "scale": scale,
191 | }
192 | mapping["sources"].append(source)
193 | for dest_ch in set(range(self.nbr_channels)) - handled_channels:
194 | logging.debug("pass through", dest_ch)
195 | mapping = {
196 | "dest": dest_ch,
197 | "mute": False,
198 | "sources": [
199 | {
200 | "channel": dest_ch,
201 | "gain": 0.0,
202 | "inverted": False,
203 | "scale": "dB",
204 | }
205 | ],
206 | }
207 | mixer["mapping"].append(mapping)
208 | return mixer
209 |
210 | # Parse a single line
211 | def parse_line(self, line):
212 | if not line or line.startswith("#") or not ":" in line:
213 | return
214 | filtname = None
215 | command_name, params = line.split(":", 1)
216 | command = command_name.split()[0]
217 | logging.debug("Parse command:", command)
218 | if command in ("Filter", "Convolution", "Preamp", "Delay"):
219 | if command == "Filter":
220 | filterparams = self.parse_filter_params(params)
221 | if not filterparams:
222 | return
223 | filter = {"type": "Biquad", "parameters": filterparams}
224 | elif command == "Convolution":
225 | filename = params.strip()
226 | filter = {
227 | "type": "Conv",
228 | "parameters": {"filename": filename, "type": "wav"},
229 | }
230 | elif command == "Preamp":
231 | filter = self.parse_gain(params)
232 | elif command == "Delay":
233 | filter = self.parse_delay(params)
234 | filter["description"] = line.strip()
235 | filtname = f"{command}_{self.name_index[command]}"
236 | self.name_index[command] += 1
237 | self.filters[filtname] = filter
238 | self.pipeline[-1]["names"].append(filtname)
239 | elif command == "Channel":
240 | if params.strip() == "all":
241 | self.selected_channels = None
242 | else:
243 | self.selected_channels = [
244 | self.lookup_channel_index(c) for c in params.strip().split(" ")
245 | ]
246 | new_filterstep = {
247 | "type": "Filter",
248 | "names": [],
249 | "description": line.strip(),
250 | "channels": copy(self.selected_channels),
251 | }
252 | self.pipeline.append(new_filterstep)
253 | elif command == "Copy":
254 | mixer = self.parse_copy(params)
255 | mixer["description"] = line.strip()
256 | mixername = f"{command}_{self.name_index[command]}"
257 | self.name_index[command] += 1
258 | self.mixers[mixername] = mixer
259 | step = {
260 | "type": "Mixer",
261 | "name": mixername,
262 | }
263 | self.pipeline.append(step)
264 | step = {
265 | "type": "Filter",
266 | "names": [],
267 | "description": "Continued after mixer",
268 | "channels": copy(self.selected_channels),
269 | }
270 | self.pipeline.append(step)
271 | elif command in (
272 | "Device",
273 | "Include",
274 | "Eval",
275 | "If",
276 | "ElseIf",
277 | "Else",
278 | "EndIf",
279 | "Stage",
280 | "GraphicEQ",
281 | ):
282 | logging.warning(f"Command '{command}' is not supported, skipping.")
283 | else:
284 | logging.warning(f"Skipping unrecognized command '{command}'")
285 |
286 | def postprocess(self):
287 | for idx, step in enumerate(list(self.pipeline)):
288 | if step["type"] == "Filter" and len(step["names"]) == 0:
289 | logging.debug("remove", step)
290 | self.pipeline.remove(step)
291 | for _, mixer in self.mixers.items():
292 | for idx, dest in enumerate(list(mixer["mapping"])):
293 | if len(dest["sources"]) == 0:
294 | mixer["mapping"].pop(idx)
295 |
296 | def build_config(self):
297 | config = {
298 | "filters": self.filters,
299 | "mixers": self.mixers,
300 | "pipeline": self.pipeline,
301 | }
302 | return config
303 |
304 | def translate_file(self):
305 | for idx, line in enumerate(self.lines):
306 | self.parse_line(line)
307 | self.postprocess()
308 | config = self.build_config()
309 | return config
310 |
--------------------------------------------------------------------------------
/backend/filemanagement.py:
--------------------------------------------------------------------------------
1 | import io
2 | import os
3 | import zipfile
4 | from copy import deepcopy
5 | from os.path import (
6 | isfile,
7 | split,
8 | join,
9 | realpath,
10 | relpath,
11 | normpath,
12 | isabs,
13 | commonpath,
14 | getmtime,
15 | getsize,
16 | )
17 | import logging
18 | import traceback
19 |
20 | import yaml
21 | from yaml.scanner import ScannerError
22 | from aiohttp import web
23 |
24 | from camilladsp import CamillaError
25 |
26 | from .legacy_config_import import identify_version
27 |
28 | DEFAULT_STATEFILE = {
29 | "config_path": None,
30 | "mute": [False, False, False, False, False],
31 | "volume": [0.0, 0.0, 0.0, 0.0, 0.0],
32 | }
33 |
34 |
35 | def file_in_folder(folder, filename):
36 | """
37 | Safely join a folder and filename.
38 | """
39 | if "/" in filename or "\\" in filename:
40 | raise IOError("Filename may not contain any slashes/backslashes")
41 | return os.path.abspath(os.path.join(folder, filename))
42 |
43 |
44 | def path_of_configfile(request, config_name):
45 | config_folder = request.app["config_dir"]
46 | return file_in_folder(config_folder, config_name)
47 |
48 |
49 | async def store_files(folder, request):
50 | """
51 | Write a set of files (raw data) to disk.
52 | """
53 | data = await request.post()
54 | i = 0
55 | while True:
56 | filename = "file{}".format(i)
57 | if filename not in data:
58 | break
59 | file = data[filename]
60 | filename = file.filename
61 | content = file.file.read()
62 | with open(file_in_folder(folder, filename), "wb") as f:
63 | f.write(content)
64 | i += 1
65 | return web.Response(text="Saved {} file(s)".format(i))
66 |
67 |
68 | def list_of_files_in_directory(folder, file_stats=True, title_and_desc=False, validator=None):
69 | """
70 | Return a list of files (name and modification date) in a folder.
71 | """
72 |
73 | files_list = []
74 | for file in os.listdir(folder):
75 | filepath = file_in_folder(folder, file)
76 | if not isfile(filepath) or file.startswith("."):
77 | # skip directories and hidden files
78 | continue
79 |
80 | file_data = {
81 | "name": file,
82 | }
83 | if file_stats:
84 | file_data["lastModified"] = getmtime(filepath)
85 | file_data["size"] = getsize(filepath)
86 |
87 | if title_and_desc:
88 | valid = False
89 | version = None
90 | errors = None
91 | title = None
92 | desc = None
93 | with open(filepath) as f:
94 | try:
95 | parsed = yaml.safe_load(f)
96 | title = parsed.get("title")
97 | desc = parsed.get("description")
98 | version = identify_version(parsed)
99 | if version == 3 and validator is not None:
100 | parsed_abs = make_config_filter_paths_absolute(parsed, folder)
101 | validator.validate_config(parsed_abs)
102 | error_list = validator.get_errors()
103 | if len(error_list) > 0:
104 | errors = error_list
105 | else:
106 | valid = True
107 | elif version < 3:
108 | valid = False
109 | errors = [([], f"This config is made for the previous version {version} of CamillaDSP.")]
110 | except yaml.YAMLError as e:
111 | if hasattr(e, 'problem_mark'):
112 | mark = e.problem_mark
113 | errordesc = f"This file has a YAML syntax error on line: {mark.line + 1}, column: {mark.column + 1}"
114 | else:
115 | errordesc = "This config file has a YAML syntax error."
116 | errors = [([], errordesc)]
117 | except (AttributeError, UnicodeDecodeError) as e:
118 | errors = [([], "This does not appear to be a YAML file.")]
119 | except Exception as e:
120 | errors = [([], f"Error: {e}")]
121 | file_data["title"] = title
122 | file_data["description"] = desc
123 | file_data["version"] = version
124 | file_data["valid"] = valid
125 | file_data["errors"] = errors
126 | files_list.append(file_data)
127 |
128 | sorted_files = sorted(files_list, key=lambda x: x["name"].lower())
129 | return sorted_files
130 |
131 |
132 | def list_of_filenames_in_directory(folder):
133 | return [file["name"] for file in list_of_files_in_directory(folder, file_stats=False)]
134 |
135 |
136 | def delete_files(folder, files):
137 | """
138 | Delete a list of files from a folder.
139 | """
140 | for file in files:
141 | path = file_in_folder(folder, file)
142 | os.remove(path)
143 |
144 |
145 | async def zip_response(request, zip_file, file_name):
146 | """
147 | Send a response with a binary file (zip).
148 | """
149 | response = web.StreamResponse()
150 | response.headers.add("Content-Disposition", "attachment; filename=" + file_name)
151 | await response.prepare(request)
152 | await response.write(zip_file)
153 | await response.write_eof()
154 | return response
155 |
156 |
157 | def zip_of_files(folder, files):
158 | """
159 | Compress a list of files to a zip.
160 | """
161 | zip_buffer = io.BytesIO()
162 | with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
163 | for file_name in files:
164 | file_path = file_in_folder(folder, file_name)
165 | with open(file_path, "r") as file:
166 | zip_file.write(file_path, file_name)
167 | return zip_buffer.getvalue()
168 |
169 |
170 | def read_yaml_from_path_to_object(request, path):
171 | """
172 | Read a yaml file at the given path, return the validated content as a Python object.
173 | """
174 | validator = request.app["VALIDATOR"]
175 | validator.validate_file(path)
176 | return validator.get_config()
177 |
178 |
179 | def get_active_config_path(request):
180 | """
181 | Get the active config filename.
182 | """
183 | statefile_path = request.app["statefile_path"]
184 | config_dir = request.app["config_dir"]
185 | cdsp = request.app["CAMILLA"]
186 | try:
187 | _state = cdsp.general.state()
188 | online = True
189 | except (CamillaError, IOError):
190 | online = False
191 | on_get = request.app["on_get_active_config"]
192 | if not on_get:
193 | if online:
194 | dsp_statefile_path = cdsp.general.state_file_path()
195 | if dsp_statefile_path:
196 | fpath = cdsp.config.file_path()
197 | filename = _verify_path_in_config_dir(fpath, config_dir)
198 | logging.debug(filename)
199 | return filename
200 | else:
201 | logging.error(
202 | "CamillaDSP runs without state file and is unable to persistently store config file path"
203 | )
204 | return None
205 | elif statefile_path:
206 | confpath = _read_statefile_config_path(statefile_path)
207 | return _verify_path_in_config_dir(confpath, config_dir)
208 | else:
209 | logging.error(
210 | "The backend config has no state file and is unable to persistently store config file path"
211 | )
212 | else:
213 | try:
214 | logging.debug(f"Running command: {on_get}")
215 | stream = os.popen(on_get)
216 | result = stream.read().strip()
217 | logging.debug(f"Command result: {result}")
218 | fname = _verify_path_in_config_dir(result, config_dir)
219 | return fname
220 | except Exception as e:
221 | logging.error(f"Failed to run on_get_active_config command")
222 | traceback.print_exc()
223 | return None
224 |
225 |
226 | def set_path_as_active_config(request, filepath):
227 | """
228 | Persistlently set the given config file path as the active config.
229 | """
230 | on_set = request.app["on_set_active_config"]
231 | statefile_path = request.app["statefile_path"]
232 | cdsp = request.app["CAMILLA"]
233 | try:
234 | _state = cdsp.general.state()
235 | online = True
236 | except (CamillaError, IOError):
237 | online = False
238 | if not online:
239 | if statefile_path:
240 | try:
241 | logging.debug(f"Update config file path in statefile to '{filepath}'")
242 | _update_statefile_config_path(statefile_path, filepath)
243 | except Exception as e:
244 | logging.error(f"Failed to update statefile at {statefile_path}")
245 | traceback.print_exc()
246 | else:
247 | logging.error(
248 | "The backend config has no state file and is unable to persistently store config file path"
249 | )
250 | else:
251 | dsp_statefile_path = cdsp.general.state_file_path()
252 | if dsp_statefile_path:
253 | logging.debug(f"Send set config file path command with '{filepath}'")
254 | cdsp.config.set_file_path(filepath)
255 | else:
256 | logging.error(
257 | "CamillaDSP runs without state file and is unable to persistently store config file path"
258 | )
259 | if on_set:
260 | try:
261 | cmd = on_set.format(f'"{filepath}"')
262 | logging.debug(f"Running command: {cmd}")
263 | os.system(cmd)
264 | except Exception as e:
265 | logging.error(f"Failed to run on_set_active_config command")
266 | traceback.print_exc()
267 |
268 |
269 | def _verify_path_in_config_dir(path, config_dir):
270 | """
271 | Verify that a given path points to a file in config_dir.
272 | Returns the filename without the rest of the path.
273 | """
274 | if path is None:
275 | logging.warning("The config file path is None")
276 | return None
277 | canonical = realpath(path)
278 | if is_path_in_folder(canonical, config_dir):
279 | _head, tail = split(canonical)
280 | return tail
281 | logging.error(
282 | f"The config file path '{path}' is not in the config dir '{config_dir}'"
283 | )
284 | return None
285 |
286 |
287 | def _update_statefile_config_path(statefile_path, new_config_path):
288 | """
289 | Read a statefile if possible, update the config filename, and write the result"
290 | """
291 | try:
292 | with open(statefile_path) as f:
293 | state = yaml.safe_load(f)
294 | except ScannerError as e:
295 | logging.error(f"Invalid yaml syntax in statefile: {statefile_path}")
296 | logging.error(f"Details: {e}")
297 | state = deepcopy(DEFAULT_STATEFILE)
298 | except OSError as e:
299 | logging.error(f"Statefile could not be opened: {statefile_path}")
300 | logging.error(f"Details: {e}")
301 | state = deepcopy(DEFAULT_STATEFILE)
302 | state["config_path"] = new_config_path
303 | yaml_state = yaml.dump(state).encode("utf-8")
304 | with open(statefile_path, "wb") as f:
305 | f.write(yaml_state)
306 |
307 |
308 | def _read_statefile_config_path(statefile_path):
309 | """
310 | Read a statefile if possible, and get the config_path"
311 | """
312 | try:
313 | with open(statefile_path) as f:
314 | state = yaml.safe_load(f)
315 | return state["config_path"]
316 | except ScannerError as e:
317 | logging.error(f"Invalid yaml syntax in statefile: {statefile_path}")
318 | logging.error(f"Details: {e}")
319 | except OSError as e:
320 | logging.error(f"Statefile could not be opened: {statefile_path}")
321 | logging.error(f"Details: {e}")
322 | return None
323 |
324 |
325 | def save_config_to_yaml_file(config_name, config_object, request):
326 | """
327 | Write a given config object to a yaml file.
328 | """
329 | config_file = path_of_configfile(request, config_name)
330 | yaml_config = yaml.dump(config_object).encode("utf-8")
331 | with open(config_file, "wb") as f:
332 | f.write(yaml_config)
333 |
334 |
335 | def coeff_dir_relative_to_config_dir(request):
336 | """
337 | Get the relative path of the coeff_dir with respect to config_dir.
338 | """
339 | relative_coeff_dir = relpath(
340 | request.app["coeff_dir"], start=request.app["config_dir"]
341 | )
342 | coeff_dir_with_folder_separator_at_end = join(relative_coeff_dir, "")
343 | return coeff_dir_with_folder_separator_at_end
344 |
345 |
346 | def make_config_filter_paths_absolute(config_object, config_dir):
347 | """
348 | Convert paths to coefficient files in a config from relative to absolute.
349 | """
350 | conversion = lambda path, config_dir=config_dir: make_absolute(path, config_dir)
351 | return convert_config_filter_paths(config_object, conversion)
352 |
353 |
354 | def make_config_filter_paths_relative(config_object, config_dir):
355 | """
356 | Convert paths to coefficient files in a config from absolute to relative.
357 | """
358 | conversion = lambda path, config_dir=config_dir: make_relative(path, config_dir)
359 | return convert_config_filter_paths(config_object, conversion)
360 |
361 |
362 | def convert_config_filter_paths(config_object, conversion):
363 | """
364 | Apply a path conversion to all filter coefficient paths of a config.
365 | """
366 | config = deepcopy(config_object)
367 | filters = config.get("filters")
368 | if filters is not None:
369 | for filter_name in filters:
370 | filt = filters[filter_name]
371 | convert_filter_path(filt, conversion)
372 | return config
373 |
374 |
375 | def convert_filter_path(filter_as_dict, conversion):
376 | """
377 | Apply a path conversion to a filter coefficient path.
378 | """
379 | ftype = filter_as_dict["type"]
380 | parameters = filter_as_dict["parameters"]
381 | if ftype == "Conv" and parameters["type"] in ["Raw", "Wav"]:
382 | filename = parameters["filename"]
383 | if filename:
384 | filename = conversion(filename)
385 | parameters["filename"] = filename
386 |
387 |
388 | def replace_relative_filter_path_with_absolute_paths(filter_as_dict, config_dir):
389 | """
390 | Convert paths to coefficient files in a config from absolute to relative.
391 | """
392 | conversion = lambda path, config_dir=config_dir: make_absolute(path, config_dir)
393 | convert_filter_path(filter_as_dict, conversion)
394 |
395 |
396 | def make_absolute(path, base_dir):
397 | """
398 | Make a relative path absolute.
399 | """
400 | return path if isabs(path) else normpath(join(base_dir, path))
401 |
402 |
403 | def replace_tokens_in_filter_config(filterconfig, samplerate, channels):
404 | """
405 | Replace tokens in coefficient file paths.
406 | """
407 | ftype = filterconfig["type"]
408 | parameters = filterconfig["parameters"]
409 | if ftype == "Conv" and parameters["type"] in ["Raw", "Wav"]:
410 | parameters["filename"] = (
411 | parameters["filename"]
412 | .replace("$samplerate$", str(samplerate))
413 | .replace("$channels$", str(channels))
414 | )
415 |
416 |
417 | def make_relative(path, base_dir):
418 | """
419 | Make a path relative to a base directory.
420 | """
421 | return relpath(path, start=base_dir) if isabs(path) else path
422 |
423 |
424 | def is_path_in_folder(path, folder):
425 | """
426 | Check if a file is in a given directory.
427 | """
428 | return folder == commonpath([path, folder])
429 |
--------------------------------------------------------------------------------
/backend/filters.py:
--------------------------------------------------------------------------------
1 | import re
2 | from os.path import splitext, basename
3 |
4 | FORMAT_MAP = {
5 | ".txt": "TEXT",
6 | ".csv": "TEXT",
7 | ".tsv": "TEXT",
8 | ".dbl": "FLOAT64LE",
9 | ".raw": "S32LE",
10 | ".pcm": "S32LE",
11 | ".dat": "S32LE",
12 | ".sam": "S32LE",
13 | ".f32": "FLOAT32LE",
14 | ".f64": "FLOAT64LE",
15 | ".i32": "S32LE",
16 | ".i24": "S24LE3",
17 | ".i16": "S16LE",
18 | }
19 |
20 |
21 | def defaults_for_filter(file_path):
22 | """
23 | Make suitable filter parameters based of coeff file ending.
24 | """
25 | extension = splitext(file_path)[1].lower()
26 | if extension == ".wav":
27 | return {"type": "Wav"}
28 | elif extension in FORMAT_MAP.keys():
29 | return {
30 | "type": "Raw",
31 | "format": FORMAT_MAP[extension],
32 | "skip_bytes_lines": 0,
33 | "read_bytes_lines": 0,
34 | }
35 | else:
36 | return {}
37 |
38 |
39 | def filter_plot_options(filter_file_names, filename):
40 | """
41 | Get the different available options for samplerate and channels for a set of coeffient files.
42 | """
43 | filename_pattern = pattern_from_filter_file_name(filename)
44 | options = []
45 | for file in filter_file_names:
46 | match = filename_pattern.match(file)
47 | if match:
48 | option = {"name": file}
49 | groups = match.groupdict()
50 | if "samplerate" in groups:
51 | option["samplerate"] = int(groups["samplerate"])
52 | if "channels" in groups:
53 | option["channels"] = int(groups["channels"])
54 | options.append(option)
55 | return options
56 |
57 |
58 | def pattern_from_filter_file_name(path):
59 | """
60 | Regex patterns for matching samplerate and channels tokens in filename.
61 | """
62 | filename = re.escape(basename(path))
63 | pattern = filename.replace(r"\$samplerate\$", "(?P\\d*)").replace(
64 | r"\$channels\$", "(?P\\d*)"
65 | )
66 | return re.compile(pattern)
67 |
68 |
69 | def pipeline_step_plot_options(filter_file_names, config, step_index):
70 | """
71 | Get the combined available samplerate and channels options for a filter step.
72 | """
73 | samplerates_and_channels_for_filter = map_of_samplerates_and_channels_per_filter(
74 | config, filter_file_names, step_index
75 | )
76 | all_samplerate_and_channel_options = set_of_all_samplerate_and_channel_options(
77 | samplerates_and_channels_for_filter
78 | )
79 | samplerate_and_channel_options = (
80 | set_of_samplerate_and_channel_options_available_for_all_filters(
81 | all_samplerate_and_channel_options, samplerates_and_channels_for_filter
82 | )
83 | )
84 | return plot_options_to_object(samplerate_and_channel_options)
85 |
86 |
87 | def map_of_samplerates_and_channels_per_filter(config, filter_file_names, step_index):
88 | """
89 | Get samplerate and channel options for a set of filters.
90 | """
91 | step_filters = config["pipeline"][step_index]["names"]
92 | default_samplerate = config["devices"]["samplerate"]
93 | default_channels = config["devices"]["capture"]["channels"]
94 | samplerates_and_channels_for_filter = {}
95 | for filter_name in step_filters:
96 | filter = config["filters"][filter_name]
97 | parameters = filter["parameters"]
98 | if filter["type"] == "Conv" and parameters["type"] in {"Raw", "Wav"}:
99 | filename = parameters["filename"]
100 | samplerates_and_channels_for_filter[
101 | filter_name
102 | ] = samplerate_and_channel_pairs_from_options(
103 | filter_plot_options(filter_file_names, filename),
104 | default_samplerate,
105 | default_channels,
106 | )
107 | return samplerates_and_channels_for_filter
108 |
109 |
110 | def samplerate_and_channel_pairs_from_options(
111 | options, default_samplerate, default_channels
112 | ):
113 | """
114 | Make a set of unique (samplerate, channels) pairs from a list of options.
115 | """
116 | pairs = set()
117 | for option in options:
118 | samplerate = (
119 | option["samplerate"] if "samplerate" in option else default_samplerate
120 | )
121 | channels = option["channels"] if "channels" in option else default_channels
122 | pairs.add((samplerate, channels))
123 | return pairs
124 |
125 |
126 | def set_of_all_samplerate_and_channel_options(samplerates_and_channels_for_filter):
127 | """
128 | Converts a map to a set with unique values.
129 | """
130 | samplerate_and_channel_options = set()
131 | for filter_name in samplerates_and_channels_for_filter:
132 | samplerate_and_channel_options.update(
133 | samplerates_and_channels_for_filter[filter_name]
134 | )
135 | return samplerate_and_channel_options
136 |
137 |
138 | def set_of_samplerate_and_channel_options_available_for_all_filters(
139 | samplerate_and_channel_options, samplerates_and_channels_for_filter
140 | ):
141 | """
142 | Append additional values to an existing set.
143 | """
144 | options_available_for_all_filters = set(samplerate_and_channel_options)
145 | for filter_name in samplerates_and_channels_for_filter:
146 | options_available_for_all_filters.intersection_update(
147 | samplerates_and_channels_for_filter[filter_name]
148 | )
149 | return options_available_for_all_filters
150 |
151 |
152 | def plot_options_to_object(samplerate_and_channel_options):
153 | """
154 | Convert samplerate/channel options to an object suitable for conversion to json.
155 | """
156 | step_options = []
157 | for option in samplerate_and_channel_options:
158 | samplerate = option[0]
159 | channels = option[1]
160 | step_options.append(
161 | {
162 | "name": str(samplerate) + " Hz - " + str(channels) + " Channels",
163 | "samplerate": samplerate,
164 | "channels": channels,
165 | }
166 | )
167 | step_options.sort(key=lambda x: x["name"])
168 | return step_options
169 |
--------------------------------------------------------------------------------
/backend/legacy_config_import.py:
--------------------------------------------------------------------------------
1 | # v1->v2 introduces the default volume control, remove old volume filters
2 | def _remove_volume_filters(config):
3 | """
4 | Remove any Volume filter without a "fader" parameter
5 | """
6 | if "filters" in config and isinstance(config["filters"], dict):
7 | volume_names = []
8 | for name, params in list(config["filters"].items()):
9 | if params["type"] == "Volume" and "fader" not in params["parameters"]:
10 | volume_names.append(name)
11 | del config["filters"][name]
12 |
13 | if "pipeline" in config and isinstance(config["pipeline"], list):
14 | for step in list(config["pipeline"]):
15 | if step["type"] == "Filter":
16 | step["names"] = [
17 | name for name in step["names"] if name not in volume_names
18 | ]
19 | if len(step["names"]) == 0:
20 | config["pipeline"].remove(step)
21 |
22 | # v1->v2 removes "ramp_time" from loudness filters
23 | def _modify_loundness_filters(config):
24 | """
25 | Modify Loudness filters
26 | """
27 | if "filters" in config and isinstance(config["filters"], dict):
28 | for name, params in config["filters"].items():
29 | if params["type"] == "Loudness":
30 | if "ramp_time" in params["parameters"]:
31 | del params["parameters"]["ramp_time"]
32 | params["parameters"]["fader"] = "Main"
33 | params["parameters"]["attenuate_mid"] = False
34 |
35 |
36 | # v1->v2 changes the resampler config
37 | def _modify_resampler(config):
38 | """
39 | Update the resampler config
40 | """
41 | if "enable_resampling" in config["devices"]:
42 | if config["devices"]["enable_resampling"]:
43 | # TODO map the easy presets, skip the free?
44 | if config["devices"]["resampler_type"] == "Synchronous":
45 | config["devices"]["resampler"] = {"type": "Synchronous"}
46 | elif config["devices"]["resampler_type"] == "FastAsync":
47 | config["devices"]["resampler"] = {
48 | "type": "AsyncSinc",
49 | "profile": "Fast",
50 | }
51 | elif config["devices"]["resampler_type"] == "BalancedAsync":
52 | config["devices"]["resampler"] = {
53 | "type": "AsyncSinc",
54 | "profile": "Balanced",
55 | }
56 | elif config["devices"]["resampler_type"] == "AccurateAsync":
57 | config["devices"]["resampler"] = {
58 | "type": "AsyncSinc",
59 | "profile": "Accurate",
60 | }
61 | elif isinstance(config["devices"]["resampler_type"], dict):
62 | old_resampler = config["devices"]["resampler_type"]
63 | if "FreeAsync" in old_resampler:
64 | params = old_resampler["FreeAsync"]
65 | new_resampler = {
66 | "type": "AsyncSinc",
67 | "sinc_len": params["sinc_len"],
68 | "oversampling_factor": params["oversampling_ratio"],
69 | "interpolation": params["interpolation"],
70 | "window": params["window"],
71 | "f_cutoff": params["f_cutoff"],
72 | }
73 | config["devices"]["resampler"] = new_resampler
74 | else:
75 | config["devices"]["resampler"] = None
76 | del config["devices"]["enable_resampling"]
77 | if "resampler_type" in config["devices"]:
78 | del config["devices"]["resampler_type"]
79 |
80 |
81 | def _modify_devices(config):
82 | """
83 | Update the options in the devices section
84 | """
85 | # New logic for setting sample format
86 | if "devices" in config:
87 | if "capture" in config["devices"]:
88 | dev = config["devices"]["capture"]
89 | _modify_coreaudio_device(dev)
90 | if "playback" in config["devices"]:
91 | dev = config["devices"]["playback"]
92 | _modify_coreaudio_device(dev)
93 | _modify_file_playback_device(dev)
94 |
95 | # Resampler
96 | _modify_resampler(config)
97 |
98 | # v1->v2 removes the "change_format" and makes "format" optional
99 | def _modify_coreaudio_device(dev):
100 | if dev["type"] == "CoreAudio":
101 | if "change_format" in dev:
102 | if not dev["change_format"]:
103 | dev["format"] = None
104 | del dev["change_format"]
105 | else:
106 | dev["format"] = None
107 |
108 | # vx-vx changes some of the file playback types
109 | def _modify_file_playback_device(dev):
110 | if dev["type"] == "File":
111 | dev["type"] = "RawFile"
112 |
113 | # v1->v2 changes some names for dither filters
114 | def _modify_dither(config):
115 | """
116 | Update Dither filters, some names have changed.
117 | Uniform -> Flat
118 | Simple -> Highpass
119 | """
120 | if "filters" in config and isinstance(config["filters"], dict):
121 | for _name, params in config["filters"].items():
122 | if params["type"] == "Dither":
123 | if params["parameters"]["type"] == "Uniform":
124 | params["parameters"]["type"] = "Flat"
125 | elif params["parameters"]["type"] == "Simple":
126 | params["parameters"]["type"] = "Highpass"
127 |
128 |
129 | def _fix_rew_pipeline(config):
130 | if "pipeline" in config:
131 | pipeline = config["pipeline"]
132 | if isinstance(pipeline, dict) and "names" in pipeline and "type" in pipeline:
133 | # This config was exported from REW.
134 | # The `pipeline` property consists of a single step instead of a list of steps.
135 | # Convert `pipeline` to a list of steps, and add the missing `channels` attribute,
136 | # but check before in case a new version of REW adds the channel(s).
137 | if "channel" not in pipeline and "channels" not in pipeline:
138 | pipeline["channels"] = None
139 | config["pipeline"] = [pipeline]
140 |
141 |
142 | # v2->v3 changes scalar "channel" to array "channels"
143 | def _modify_pipeline_filter_steps(config):
144 | if "pipeline" in config and isinstance(config["pipeline"], list):
145 | for step in config["pipeline"]:
146 | if step["type"] == "Filter":
147 | if "channel" in step:
148 | step["channels"] = [step["channel"]]
149 | del step["channel"]
150 |
151 |
152 | def migrate_legacy_config(config):
153 | """
154 | Modifies an older config file to the latest format.
155 | The modifications are done in-place.
156 | """
157 | _fix_rew_pipeline(config)
158 | _remove_volume_filters(config)
159 | _modify_loundness_filters(config)
160 | _modify_dither(config)
161 | _modify_devices(config)
162 | _modify_pipeline_filter_steps(config)
163 |
164 |
165 | def _look_for_v1_volume(config):
166 | if "filters" in config and isinstance(config["filters"], dict):
167 | for name, params in list(config["filters"].items()):
168 | if params["type"] == "Volume" and "fader" not in params["parameters"]:
169 | return True
170 | return False
171 |
172 | def _look_for_v1_loudness(config):
173 | if "filters" in config and isinstance(config["filters"], dict):
174 | for name, params in config["filters"].items():
175 | if params["type"] == "Loudness" and "ramp_time" in params["parameters"]:
176 | return True
177 | return False
178 |
179 | def _look_for_v1_resampler(config):
180 | return "devices" in config and "enable_resampling" in config["devices"]
181 |
182 | def _look_for_v1_devices(config):
183 | if "devices" in config:
184 | for direction in ("capture", "playback"):
185 | if direction in config["devices"] and "type" in config["devices"][direction]:
186 | if config["devices"][direction]["type"] == "CoreAudio" and "change_format" in config["devices"][direction]:
187 | return True
188 | return False
189 |
190 | def _look_for_v2_devices(config):
191 | return "devices" in config and "capture" in config["devices"] and config["devices"]["capture"]["type"] == "File"
192 |
193 | def _look_for_v1_dither(config):
194 | if "filters" in config and isinstance(config["filters"], dict):
195 | for _name, params in config["filters"].items():
196 | if params["type"] == "Dither":
197 | if params["parameters"]["type"] in ("Uniform", "Simple"):
198 | return True
199 | return False
200 |
201 | def _look_for_v2_pipeline(config):
202 | if "pipeline" in config and isinstance(config["pipeline"], list):
203 | for step in config["pipeline"]:
204 | if step["type"] == "Filter":
205 | if "channel" in step:
206 | return True
207 | return False
208 |
209 | def identify_version(config):
210 | if _look_for_v1_volume(config):
211 | return 1
212 | if _look_for_v1_loudness(config):
213 | return 1
214 | if _look_for_v1_resampler(config):
215 | return 1
216 | if _look_for_v1_devices(config):
217 | return 1
218 | if _look_for_v1_dither(config):
219 | return 1
220 | if _look_for_v2_pipeline(config):
221 | return 2
222 | if _look_for_v2_devices(config):
223 | return 2
224 | return 3
225 |
--------------------------------------------------------------------------------
/backend/routes.py:
--------------------------------------------------------------------------------
1 | from .settings import BASEPATH
2 | from .views import (
3 | get_param,
4 | get_list_param,
5 | get_param_json,
6 | set_param,
7 | set_param_index,
8 | eval_filter_values,
9 | eval_filterstep_values,
10 | get_config,
11 | set_config,
12 | get_active_config_file,
13 | get_default_config_file,
14 | set_active_config_name,
15 | config_to_yml,
16 | yaml_to_json,
17 | translate_convolver_to_json,
18 | translate_eqapo_to_json,
19 | parse_and_validate_yml_config_to_json,
20 | validate_config,
21 | get_gui_index,
22 | get_stored_coeffs,
23 | get_stored_configs,
24 | store_configs,
25 | store_coeffs,
26 | delete_coeffs,
27 | delete_configs,
28 | download_coeffs_zip,
29 | download_configs_zip,
30 | get_gui_config,
31 | get_config_file,
32 | save_config_file,
33 | get_defaults_for_coeffs,
34 | get_status,
35 | get_log_file,
36 | get_capture_devices,
37 | get_playback_devices,
38 | get_backends,
39 | get_wav_info,
40 | )
41 |
42 |
43 | def setup_routes(app):
44 | app.router.add_get("/api/status", get_status)
45 | app.router.add_get("/api/getparam/{name}", get_param)
46 | app.router.add_get("/api/getparamjson/{name}", get_param_json)
47 | app.router.add_get("/api/getlistparam/{name}", get_list_param)
48 | app.router.add_post("/api/setparam/{name}", set_param)
49 | app.router.add_post("/api/setparamindex/{name}/{index}", set_param_index)
50 | app.router.add_post("/api/evalfilter", eval_filter_values)
51 | app.router.add_post("/api/evalfilterstep", eval_filterstep_values)
52 | app.router.add_get("/api/getconfig", get_config)
53 | app.router.add_post("/api/setconfig", set_config)
54 | app.router.add_get("/api/getactiveconfigfile", get_active_config_file)
55 | app.router.add_get("/api/getdefaultconfigfile", get_default_config_file)
56 | app.router.add_post("/api/setactiveconfigfile", set_active_config_name)
57 | app.router.add_post("/api/configtoyml", config_to_yml)
58 | app.router.add_post(
59 | "/api/ymlconfigtojsonconfig", parse_and_validate_yml_config_to_json
60 | )
61 | app.router.add_post("/api/ymltojson", yaml_to_json)
62 | app.router.add_post("/api/convolvertojson", translate_convolver_to_json)
63 | app.router.add_post("/api/eqapotojson", translate_eqapo_to_json)
64 | app.router.add_post("/api/validateconfig", validate_config)
65 | app.router.add_get("/api/wavinfo", get_wav_info)
66 | app.router.add_get("/api/storedconfigs", get_stored_configs)
67 | app.router.add_get("/api/storedcoeffs", get_stored_coeffs)
68 | app.router.add_get("/api/defaultsforcoeffs", get_defaults_for_coeffs)
69 | app.router.add_post("/api/uploadconfigs", store_configs)
70 | app.router.add_post("/api/uploadcoeffs", store_coeffs)
71 | app.router.add_post("/api/deleteconfigs", delete_configs)
72 | app.router.add_post("/api/deletecoeffs", delete_coeffs)
73 | app.router.add_post("/api/downloadconfigszip", download_configs_zip)
74 | app.router.add_post("/api/downloadcoeffszip", download_coeffs_zip)
75 | app.router.add_get("/api/guiconfig", get_gui_config)
76 | app.router.add_get("/api/getconfigfile", get_config_file)
77 | app.router.add_post("/api/saveconfigfile", save_config_file)
78 | app.router.add_get("/api/logfile", get_log_file)
79 | app.router.add_get("/api/capturedevices/{backend}", get_capture_devices)
80 | app.router.add_get("/api/playbackdevices/{backend}", get_playback_devices)
81 | app.router.add_get("/api/backends", get_backends)
82 |
83 | app.router.add_get("/", get_gui_index)
84 |
85 |
86 | def setup_static_routes(app):
87 | app.router.add_static("/gui/", path=BASEPATH / "build")
88 | app.router.add_static("/config/", path=app["config_dir"])
89 | app.router.add_static("/coeff/", path=app["coeff_dir"])
90 |
--------------------------------------------------------------------------------
/backend/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pathlib
3 | import sys
4 |
5 | import yaml
6 | from yaml.scanner import ScannerError
7 | from jsonschema import Draft202012Validator
8 |
9 | import logging
10 |
11 | from .settings_schemas import GUI_CONFIG_SCHEMA, BACKEND_CONFIG_SCHEMA
12 |
13 | BASEPATH = pathlib.Path(__file__).parent.parent.absolute()
14 | CONFIG_PATH = BASEPATH / "config" / "camillagui.yml"
15 | GUI_CONFIG_PATH = BASEPATH / "config" / "gui-config.yml"
16 |
17 | # Default values for the optional gui config.
18 | GUI_CONFIG_DEFAULTS = {
19 | "hide_capture_samplerate": False,
20 | "hide_silence": False,
21 | "hide_capture_device": False,
22 | "hide_playback_device": False,
23 | "hide_multithreading": False,
24 | "apply_config_automatically": False,
25 | "save_config_automatically": False,
26 | "status_update_interval": 100,
27 | "volume_range": 50,
28 | "volume_max": 0,
29 | }
30 |
31 | # Default values for the optional settings.
32 | BACKEND_CONFIG_DEFAULTS = {
33 | "default_config": None,
34 | "statefile_path": None,
35 | "on_set_active_config": None,
36 | "on_get_active_config": None,
37 | "supported_capture_types": None,
38 | "supported_playback_types": None,
39 | "log_file": None,
40 | }
41 |
42 |
43 | def _load_yaml(path):
44 | """
45 | Load a yaml file into a dict.
46 | Logs the error and returns None if the file can't be read.
47 | """
48 | try:
49 | with open(path) as f:
50 | config = yaml.safe_load(f)
51 | return config
52 | except ScannerError as e:
53 | logging.error(f"Invalid yaml syntax in config file: {path}")
54 | logging.error(f"Details: {e}")
55 | except OSError as e:
56 | logging.error(f"Config file could not be opened: {path}")
57 | logging.error(f"Details: {e}")
58 | return None
59 |
60 |
61 | def _read_and_validate_file(path, schema):
62 | config = _load_yaml(path)
63 | if config is None:
64 | return None
65 | validator = Draft202012Validator(schema)
66 | errors = list(validator.iter_errors(config))
67 | if len(errors) > 0:
68 | logging.error(f"Error in config file '{path}'")
69 | for e in errors:
70 | logging.error(f"Parameter '{'/'.join([str(p) for p in e.path])}': {e.message}")
71 | return None
72 | return config
73 |
74 | def get_config(path):
75 | """
76 | Get backend config.
77 | Exits if the config can't be read.
78 | """
79 | config = _read_and_validate_file(path, BACKEND_CONFIG_SCHEMA)
80 | if config is None:
81 | sys.exit()
82 | config["config_dir"] = os.path.abspath(os.path.expanduser(config["config_dir"]))
83 | config["coeff_dir"] = os.path.abspath(os.path.expanduser(config["coeff_dir"]))
84 | config["default_config"] = absolute_path_or_none_if_empty(config["default_config"])
85 | config["statefile_path"] = absolute_path_or_none_if_empty(config["statefile_path"])
86 | config["gui_config_file"] = absolute_path_or_none_if_empty(config["gui_config_file"])
87 | for key, value in BACKEND_CONFIG_DEFAULTS.items():
88 | if key not in config:
89 | config[key] = value
90 | logging.debug("Backend configuration:")
91 | logging.debug(yaml.dump(config))
92 |
93 | config["can_update_active_config"] = can_update_active_config(config)
94 |
95 | # Read the gui config.
96 | # This is only to validate the file and log any problems.
97 | # The result is not used.
98 | gui_config_path = config["gui_config_file"]
99 | if gui_config_path is None:
100 | gui_config_path = GUI_CONFIG_PATH
101 | get_gui_config_or_defaults(gui_config_path)
102 |
103 | return config
104 |
105 |
106 | def can_update_active_config(config):
107 | """
108 | Check if the backend is able to persist the active config filename.
109 | """
110 | statefile_supported = False
111 | external_supported = False
112 | if config["statefile_path"]:
113 | statefile = config["statefile_path"]
114 | is_writable = is_file_writable(statefile)
115 | if is_writable:
116 | statefile_supported = True
117 | else:
118 | logging.error(f"The statefile {statefile} is not writable.")
119 | if config["on_set_active_config"] and config["on_get_active_config"]:
120 | logging.debug(
121 | "Both 'on_set_active_config' and 'on_get_active_config' options are set"
122 | )
123 | external_supported = True
124 | return statefile_supported or external_supported
125 |
126 |
127 | def is_file_writable(path):
128 | """
129 | Check if a filename can be written to.
130 | If the file doesn't already exist it checks if it's possible
131 | to create a file in the parent directory.
132 | """
133 | exists = os.path.isfile(path)
134 | if exists:
135 | return _is_writable(path)
136 | else:
137 | parent = os.path.dirname(path)
138 | return _is_writable(parent)
139 |
140 |
141 | def _is_writable(path):
142 | """
143 | Helper to check write permission on a symlink, file or dir.
144 | """
145 | if os.access in os.supports_follow_symlinks:
146 | return os.access(path, os.W_OK, follow_symlinks=False)
147 | else:
148 | return os.access(path, os.W_OK)
149 |
150 |
151 | def absolute_path_or_none_if_empty(path):
152 | """
153 | Make a path absolute, of return None if the given path is empty.
154 | """
155 | if path:
156 | return os.path.abspath(os.path.expanduser(path))
157 | else:
158 | return None
159 |
160 |
161 | def get_gui_config_or_defaults(path):
162 | """
163 | Get the gui config from file if it exists,
164 | if not return the defaults.
165 | """
166 | config = _read_and_validate_file(path, GUI_CONFIG_SCHEMA)
167 | if config is not None:
168 | for key, value in GUI_CONFIG_DEFAULTS.items():
169 | if key not in config:
170 | config[key] = value
171 | return config
172 | else:
173 | logging.warning("Unable to read gui config file, using defaults")
174 | return GUI_CONFIG_DEFAULTS
175 |
176 |
--------------------------------------------------------------------------------
/backend/settings_schemas.py:
--------------------------------------------------------------------------------
1 | BACKEND_CONFIG_SCHEMA = {
2 | "type": "object",
3 | "properties": {
4 | "camilla_host": {"type": "string", "minLength": 1},
5 | "camilla_port": {
6 | "type": "integer",
7 | },
8 | "bind_address": {"type": "string", "minLength": 1},
9 | "port": {
10 | "type": "integer",
11 | },
12 | "ssl_certificate": {"type": ["string", "null"], "minLength": 1},
13 | "ssl_private_key": {"type": ["string", "null"], "minLength": 1},
14 | "gui_config_file": {"type": ["string", "null"], "minLength": 1},
15 | "config_dir": {"type": "string", "minLength": 1},
16 | "coeff_dir": {"type": "string", "minLength": 1},
17 | "default_config": {"type": ["string", "null"], "minLength": 1},
18 | "statefile_path": {"type": ["string", "null"], "minLength": 1},
19 | "log_file": {"type": ["string", "null"], "minLength": 1},
20 | "on_set_active_config": {"type": ["string", "null"], "minLength": 1},
21 | "on_get_active_config": {"type": ["string", "null"], "minLength": 1},
22 | "supported_capture_types": {
23 | "type": ["array", "null"],
24 | "items": {"type": "string", "minLength": 1},
25 | },
26 | "supported_playback_types": {
27 | "type": ["array", "null"],
28 | "items": {"type": "string", "minLength": 1},
29 | },
30 | },
31 | "required": [
32 | "camilla_host",
33 | "camilla_port",
34 | "bind_address",
35 | "port",
36 | "config_dir",
37 | "coeff_dir",
38 | ],
39 | }
40 |
41 | GUI_CONFIG_SCHEMA = {
42 | "type": "object",
43 | "properties": {
44 | "hide_capture_samplerate": {"type": "boolean"},
45 | "hide_silence": {"type": "boolean"},
46 | "hide_capture_device": {"type": "boolean"},
47 | "hide_playback_device": {"type": "boolean"},
48 | "hide_multithreading": {"type": "boolean"},
49 | "apply_config_automatically": {"type": "boolean"},
50 | "save_config_automatically": {"type": "boolean"},
51 | "status_update_interval": {"type": "integer", "minValue": 1},
52 | "volume_range": {"type": "number", "exclusiveMinimum": 0, "maxValue": 200},
53 | "volume_max": {"type": "integer", "minValue": -100, "maxValue": 50},
54 | "custom_shortcuts": {
55 | "type": ["array", "null"],
56 | "items": {
57 | "type": "object",
58 | "properties": {
59 | "section": {"type": "string"},
60 | "description": {"type": "string"},
61 | "shortcuts": {
62 | "type": "array",
63 | "items": {
64 | "type": "object",
65 | "properties": {
66 | "name": {"type": "string"},
67 | "config_elements": {
68 | "type": "array",
69 | "items": {
70 | "type": "object",
71 | "properties": {
72 | "path": {
73 | "type": "array",
74 | "items": {"type": "string"},
75 | "minLength": 1
76 | },
77 | "reverse": {"type": ["boolean", "null"]},
78 | },
79 | "required": ["path"],
80 | }
81 | },
82 | "range_from": {"type": "number"},
83 | "range_to": {"type": "number"},
84 | "step": {"type": "number", "exclusiveMinimum": 0},
85 | "type": {
86 | "type": ["string", "null"],
87 | "enum": ["boolean", "number"]
88 | },
89 | },
90 | "if": {
91 | "properties": {
92 | "type": {
93 | "const": "number"
94 | }
95 | }
96 | },
97 | "then": {
98 | "required": [
99 | "range_from", "range_to", "step"
100 | ]
101 | },
102 | "required": [
103 | "name",
104 | "config_elements"
105 | ],
106 | },
107 | },
108 | },
109 | "required": ["section", "shortcuts"],
110 | },
111 | },
112 | },
113 | "required": [],
114 | }
115 |
116 |
--------------------------------------------------------------------------------
/backend/version.py:
--------------------------------------------------------------------------------
1 | VERSION = (3, 0, 3)
2 |
--------------------------------------------------------------------------------
/backend/views.py:
--------------------------------------------------------------------------------
1 | from os.path import isfile, expanduser, join
2 | import yaml
3 | import threading
4 | import time
5 | from aiohttp import web
6 | from camilladsp import CamillaError
7 | from camilladsp_plot import eval_filter, eval_filterstep
8 | from camilladsp_plot.audiofileread import read_wav_header
9 | import logging
10 | import traceback
11 |
12 | from .filemanagement import (
13 | path_of_configfile,
14 | store_files,
15 | list_of_files_in_directory,
16 | delete_files,
17 | zip_response,
18 | zip_of_files,
19 | read_yaml_from_path_to_object,
20 | set_path_as_active_config,
21 | get_active_config_path,
22 | save_config_to_yaml_file,
23 | make_config_filter_paths_absolute,
24 | coeff_dir_relative_to_config_dir,
25 | replace_relative_filter_path_with_absolute_paths,
26 | make_config_filter_paths_relative,
27 | make_absolute,
28 | replace_tokens_in_filter_config,
29 | list_of_filenames_in_directory,
30 | )
31 | from .filters import (
32 | defaults_for_filter,
33 | filter_plot_options,
34 | pipeline_step_plot_options,
35 | )
36 | from .settings import get_gui_config_or_defaults, GUI_CONFIG_PATH
37 | from .convolver_config_import import ConvolverConfig
38 | from .eqapo_config_import import EqAPO
39 | from .legacy_config_import import migrate_legacy_config
40 |
41 | OFFLINE_CACHE = {
42 | "cdsp_status": "Offline",
43 | "cdsp_version": "(offline)",
44 | "capturesignalrms": [],
45 | "capturesignalpeak": [],
46 | "playbacksignalrms": [],
47 | "playbacksignalpeak": [],
48 | "capturerate": None,
49 | "rateadjust": None,
50 | "bufferlevel": None,
51 | "clippedsamples": None,
52 | "processingload": None,
53 | }
54 | HEADERS = {"Cache-Control": "no-store"}
55 |
56 |
57 | async def get_gui_index(request):
58 | """
59 | Serve the static gui files.
60 | """
61 | raise web.HTTPFound("/gui/index.html")
62 |
63 |
64 | def _reconnect(cdsp, cache, validator):
65 | done = False
66 | while not done:
67 | try:
68 | cdsp.connect()
69 | cache["cdsp_version"] = version_string(cdsp.versions.camilladsp())
70 | # Update backends
71 | backends = cdsp.general.supported_device_types()
72 | cache["backends"] = backends
73 | pb_backends, cap_backends = backends
74 | logging.debug(f"Updated backends: {backends}")
75 | validator.set_supported_capture_types(cap_backends)
76 | validator.set_supported_playback_types(pb_backends)
77 | # Update playback and capture devices
78 | for pb_backend in pb_backends:
79 | pb_devs = cdsp.general.list_playback_devices(pb_backend)
80 | logging.debug(f"Updated {pb_backend} playback devices: {pb_devs}")
81 | cache["playback_devices"][pb_backend] = pb_devs
82 | for cap_backend in cap_backends:
83 | cap_devs = cdsp.general.list_capture_devices(cap_backend)
84 | logging.debug(f"Updated {cap_backend} capture devices: {cap_devs}")
85 | cache["capture_devices"][cap_backend] = cap_devs
86 | done = True
87 | except IOError:
88 | time.sleep(1)
89 |
90 |
91 | async def get_status(request):
92 | """
93 | Get the state and singnal levels etc.
94 | If this fails it spawns a thread that tries to reconnect
95 | to the camilladsp process.
96 | """
97 | cdsp = request.app["CAMILLA"]
98 | reconnect_thread = request.app["STORE"]["reconnect_thread"]
99 | cache = request.app["STATUSCACHE"]
100 | cachetime = request.app["STORE"]["cache_time"]
101 | validator = request.app["VALIDATOR"]
102 | try:
103 | levels_since = float(request.query.get("since"))
104 | except:
105 | levels_since = None
106 | try:
107 | state = cdsp.general.state()
108 | state_str = state.name
109 | cache["cdsp_status"] = state_str
110 | try:
111 | if levels_since is not None:
112 | levels = cdsp.levels.levels_since(levels_since)
113 | else:
114 | levels = cdsp.levels.levels()
115 | cache.update(
116 | {
117 | "capturesignalrms": levels["capture_rms"],
118 | "capturesignalpeak": levels["capture_peak"],
119 | "playbacksignalrms": levels["playback_rms"],
120 | "playbacksignalpeak": levels["playback_peak"],
121 | }
122 | )
123 | now = time.time()
124 | # These values don't change that fast, let's update them only once per second.
125 | if now - cachetime > 1.0:
126 | request.app["STORE"]["cache_time"] = now
127 | cache.update(
128 | {
129 | "capturerate": cdsp.rate.capture(),
130 | "rateadjust": cdsp.status.rate_adjust(),
131 | "bufferlevel": cdsp.status.buffer_level(),
132 | "clippedsamples": cdsp.status.clipped_samples(),
133 | "processingload": cdsp.status.processing_load(),
134 | }
135 | )
136 | except IOError as e:
137 | #print("TODO safe to remove this try-except? error:", e)
138 | pass
139 | except IOError:
140 | if reconnect_thread is None or not reconnect_thread.is_alive():
141 | cache.update(OFFLINE_CACHE)
142 | reconnect_thread = threading.Thread(
143 | target=_reconnect, args=(cdsp, cache, validator), daemon=True
144 | )
145 | reconnect_thread.start()
146 | request.app["STORE"]["reconnect_thread"] = reconnect_thread
147 | return web.json_response(cache, headers=HEADERS)
148 |
149 |
150 | def version_string(version_array):
151 | """
152 | Build a version string from a list of parts.
153 | """
154 | return f"{version_array[0]}.{version_array[1]}.{version_array[2]}"
155 |
156 |
157 | async def get_param(request):
158 | """
159 | Combined getter for several parameters.
160 | """
161 | name = request.match_info["name"]
162 | cdsp = request.app["CAMILLA"]
163 | if name == "volume":
164 | result = cdsp.volume.main_volume()
165 | elif name == "mute":
166 | result = cdsp.volume.main_mute()
167 | elif name == "signalrange":
168 | result = cdsp.levels.range()
169 | elif name == "signalrangedb":
170 | result = cdsp.levels.range_db()
171 | elif name == "capturerateraw":
172 | result = cdsp.rate.rate_raw()
173 | elif name == "updateinterval":
174 | result = cdsp.settings.update_interval()
175 | elif name == "configname":
176 | result = cdsp.config.file_path()
177 | elif name == "configraw":
178 | result = cdsp.config.active_raw()
179 | elif name == "processingload":
180 | result = cdsp.status.processing_load()
181 | else:
182 | raise web.HTTPNotFound(text=f"Unknown parameter {name}")
183 | return web.Response(text=str(result), headers=HEADERS)
184 |
185 | async def get_param_json(request):
186 | """
187 | Combined getter for several parameters, returns json.
188 | """
189 | name = request.match_info["name"]
190 | cdsp = request.app["CAMILLA"]
191 | if name == "faders":
192 | result = cdsp.volume.all()
193 | else:
194 | raise web.HTTPNotFound(text=f"Unknown parameter {name}")
195 | return web.json_response(result, headers=HEADERS)
196 |
197 | async def get_list_param(request):
198 | """
199 | Combined getter for several parameters where the values are lists.
200 | """
201 | name = request.match_info["name"]
202 | cdsp = request.app["CAMILLA"]
203 | if name == "capturesignalpeak":
204 | result = cdsp.levels.capture_peak()
205 | elif name == "playbacksignalpeak":
206 | result = cdsp.levels.playback_peak()
207 | else:
208 | result = "[]"
209 | return web.json_response(result, headers=HEADERS)
210 |
211 |
212 | async def set_param(request):
213 | """
214 | Combined setter for various parameters
215 | """
216 | value = await request.text()
217 | name = request.match_info["name"]
218 | cdsp = request.app["CAMILLA"]
219 | if name == "volume":
220 | cdsp.volume.set_main_volume(value)
221 | elif name == "mute":
222 | if value.lower() == "true":
223 | cdsp.volume.set_main_mute(True)
224 | elif value.lower() == "false":
225 | cdsp.volume.set_main_mute(False)
226 | else:
227 | raise web.HTTPBadRequest(text=f"Invalid boolean value {value}")
228 | elif name == "updateinterval":
229 | cdsp.settings.set_update_interval(value)
230 | elif name == "configname":
231 | cdsp.config.set_file_path(value)
232 | elif name == "configraw":
233 | cdsp.config.set_active_raw(value)
234 | return web.Response(text="OK", headers=HEADERS)
235 |
236 |
237 | async def set_param_index(request):
238 | """
239 | Combined setter for various parameters taking an additional index parameter
240 | """
241 | value = await request.text()
242 | name = request.match_info["name"]
243 | index = request.match_info["index"]
244 | cdsp = request.app["CAMILLA"]
245 | if name == "volume":
246 | cdsp.volume.set_volume(int(index), value)
247 | elif name == "mute":
248 | if value.lower() == "true":
249 | cdsp.volume.set_mute(int(index), True)
250 | elif value.lower() == "false":
251 | cdsp.volume.set_mute(int(index), False)
252 | else:
253 | raise web.HTTPBadRequest(text=f"Invalid boolean value {value}")
254 | return web.Response(text="OK", headers=HEADERS)
255 |
256 | async def eval_filter_values(request):
257 | """
258 | Evaluate a filter. Returns values for plotting.
259 | """
260 | content = await request.json()
261 | config_dir = request.app["config_dir"]
262 | config = content["config"]
263 | replace_relative_filter_path_with_absolute_paths(config, config_dir)
264 | channels = content["channels"]
265 | samplerate = content["samplerate"]
266 | volume = content.get("volume", 0.0)
267 | filter_file_names = list_of_filenames_in_directory(request.app["coeff_dir"])
268 | if "filename" in config["parameters"]:
269 | filename = config["parameters"]["filename"]
270 | options = filter_plot_options(filter_file_names, filename)
271 | else:
272 | options = []
273 | replace_tokens_in_filter_config(config, samplerate, channels)
274 | try:
275 | data = eval_filter(
276 | config,
277 | name=(content["name"]),
278 | samplerate=samplerate,
279 | npoints=1000,
280 | volume=volume
281 | )
282 | data["channels"] = channels
283 | data["options"] = options
284 | return web.json_response(data, headers=HEADERS)
285 | except FileNotFoundError:
286 | raise web.HTTPNotFound(text="Filter coefficient file not found")
287 | except Exception as e:
288 | raise web.HTTPBadRequest(text=str(e))
289 |
290 |
291 | async def eval_filterstep_values(request):
292 | """
293 | Evaluate a filter step consisting of one or several filters. Returns values for plotting.
294 | """
295 | content = await request.json()
296 | config = content["config"]
297 | step_index = content["index"]
298 | config_dir = request.app["config_dir"]
299 | samplerate = content["samplerate"]
300 | channels = content["channels"]
301 | config["devices"]["samplerate"] = samplerate
302 | config["devices"]["capture"]["channels"] = channels
303 | plot_config = make_config_filter_paths_absolute(config, config_dir)
304 | filter_file_names = list_of_filenames_in_directory(request.app["coeff_dir"])
305 | options = pipeline_step_plot_options(filter_file_names, config, step_index)
306 | for _, filt in plot_config.get("filters", {}).items():
307 | replace_tokens_in_filter_config(filt, samplerate, channels)
308 | try:
309 | data = eval_filterstep(
310 | plot_config,
311 | step_index,
312 | name="Filterstep {}".format(step_index),
313 | npoints=1000,
314 | )
315 | data["channels"] = channels
316 | data["options"] = options
317 | return web.json_response(data, headers=HEADERS)
318 | except FileNotFoundError:
319 | raise web.HTTPNotFound(text="Filter coefficient file not found")
320 | except Exception as e:
321 | raise web.HTTPBadRequest(text=str(e))
322 |
323 |
324 | async def get_config(request):
325 | """
326 | Get running config.
327 | """
328 | cdsp = request.app["CAMILLA"]
329 | config = cdsp.config.active()
330 | return web.json_response(config, headers=HEADERS)
331 |
332 |
333 | async def set_config(request):
334 | """
335 | Apply a new config to CamillaDSP.
336 | """
337 | json = await request.json()
338 | config_object = json["config"]
339 | config_dir = request.app["config_dir"]
340 | cdsp = request.app["CAMILLA"]
341 | validator = request.app["VALIDATOR"]
342 | config_object_with_absolute_filter_paths = make_config_filter_paths_absolute(
343 | config_object, config_dir
344 | )
345 | if cdsp.is_connected():
346 | try:
347 | cdsp.config.set_active(config_object_with_absolute_filter_paths)
348 | except CamillaError as e:
349 | raise web.HTTPInternalServerError(text=str(e))
350 | else:
351 | validator.validate_config(config_object_with_absolute_filter_paths)
352 | errors = validator.get_errors()
353 | if len(errors) > 0:
354 | return web.json_response(data=errors, headers=HEADERS)
355 | return web.Response(text="OK", headers=HEADERS)
356 |
357 |
358 | async def get_default_config_file(request):
359 | """
360 | Fetch the default config from file.
361 | """
362 | default_config = request.app["default_config"]
363 | config_dir = request.app["config_dir"]
364 | if default_config and isfile(default_config):
365 | config = default_config
366 | else:
367 | raise web.HTTPNotFound(text="No default config")
368 | try:
369 | config_object = make_config_filter_paths_relative(
370 | read_yaml_from_path_to_object(request, config), config_dir
371 | )
372 | except CamillaError as e:
373 | logging.error(f"Failed to get default config file, error: {e}")
374 | raise web.HTTPInternalServerError(text=str(e))
375 | except Exception as e:
376 | logging.error("Failed to get default config file")
377 | traceback.print_exc()
378 | raise web.HTTPInternalServerError(text=str(e))
379 | return web.json_response(config_object, headers=HEADERS)
380 |
381 |
382 | async def get_active_config_file(request):
383 | """
384 | Get the active config. If no config is active, return the default config.
385 | """
386 | active_config_path = get_active_config_path(request)
387 | logging.debug(active_config_path)
388 | default_config_path = request.app["default_config"]
389 | config_dir = request.app["config_dir"]
390 | if active_config_path and isfile(join(config_dir, active_config_path)):
391 | config = join(config_dir, active_config_path)
392 | elif default_config_path and isfile(default_config_path):
393 | config = default_config_path
394 | else:
395 | raise web.HTTPNotFound(text="No active or default config")
396 | try:
397 | config_object = make_config_filter_paths_relative(
398 | read_yaml_from_path_to_object(request, config), config_dir
399 | )
400 | except CamillaError as e:
401 | logging.error(f"Failed to get active config from CamillaDSP, error: {e}")
402 | raise web.HTTPInternalServerError(text=str(e))
403 | except Exception as e:
404 | logging.error(f"Failed to get active config")
405 | traceback.print_exc()
406 | raise web.HTTPInternalServerError(text=str(e))
407 | if active_config_path:
408 | data = {"configFileName": active_config_path, "config": config_object}
409 | else:
410 | data = {"config": config_object}
411 | return web.json_response(data, headers=HEADERS)
412 |
413 |
414 | async def set_active_config_name(request):
415 | """
416 | Persístently set the given config file name as the active config.
417 | """
418 | json = await request.json()
419 | config_name = json["name"]
420 | config_file = path_of_configfile(request, config_name)
421 | set_path_as_active_config(request, config_file)
422 | return web.Response(text="OK", headers=HEADERS)
423 |
424 |
425 | async def get_config_file(request):
426 | """
427 | Read and return a config file. Takes a filname and tries to load the file from config_dir.
428 | """
429 | config_dir = request.app["config_dir"]
430 | config_name = request.query["name"]
431 | migrate = request.query.get("migrate", False)
432 | config_file = path_of_configfile(request, config_name)
433 | try:
434 | config_object = make_config_filter_paths_relative(
435 | read_yaml_from_path_to_object(request, config_file), config_dir
436 | )
437 | if migrate:
438 | migrate_legacy_config(config_object)
439 | except CamillaError as e:
440 | raise web.HTTPInternalServerError(text=str(e))
441 | return web.json_response(config_object, headers=HEADERS)
442 |
443 |
444 | async def save_config_file(request):
445 | """
446 | Save a config to a given filename.
447 | """
448 | content = await request.json()
449 | save_config_to_yaml_file(content["filename"], content["config"], request)
450 | return web.Response(text="OK", headers=HEADERS)
451 |
452 |
453 | async def config_to_yml(request):
454 | """
455 | Convert a json config to yaml string (for saving to disk etc).
456 | """
457 | content = await request.json()
458 | conf_yml = yaml.dump(content)
459 | return web.Response(text=conf_yml, headers=HEADERS)
460 |
461 |
462 | async def parse_and_validate_yml_config_to_json(request):
463 | """
464 | Parse a yaml config string and return serialized as json.
465 | """
466 | config_yaml = await request.text()
467 | validator = request.app["VALIDATOR"]
468 | validator.validate_yamlstring(config_yaml)
469 | config = validator.get_config()
470 | return web.json_response(config, headers=HEADERS)
471 |
472 |
473 | async def yaml_to_json(request):
474 | """
475 | Parse a yaml string and return serialized as json.
476 | This could also be just a partial config.
477 | The config is migrated from older camilladsp versions if needed.
478 | """
479 | config_yaml = await request.text()
480 | loaded = yaml.safe_load(config_yaml)
481 | migrate_legacy_config(loaded)
482 | return web.json_response(loaded, headers=HEADERS)
483 |
484 |
485 | async def translate_convolver_to_json(request):
486 | """
487 | Parse a Convolver config string and return
488 | as a CamillaDSP config serialized as json.
489 | """
490 | config = await request.text()
491 | translated = ConvolverConfig(config).to_object()
492 | return web.json_response(translated, headers=HEADERS)
493 |
494 |
495 | async def translate_eqapo_to_json(request):
496 | """
497 | Parse a Convolver config string and return
498 | as a CamillaDSP config serialized as json.
499 | """
500 | try:
501 | channels = int(request.rel_url.query.get("channels", None))
502 | except (ValueError, TypeError) as e:
503 | raise web.HTTPBadRequest(reason=str(e))
504 | config = await request.text()
505 | converter = EqAPO(config, channels)
506 | converter.translate_file()
507 | translated = converter.build_config()
508 | return web.json_response(translated, headers=HEADERS)
509 |
510 |
511 | async def validate_config(request):
512 | """
513 | Validate a config, returned a list of errors or OK.
514 | """
515 | config_dir = request.app["config_dir"]
516 | config = await request.json()
517 | config_with_absolute_filter_paths = make_config_filter_paths_absolute(
518 | config, config_dir
519 | )
520 | validator = request.app["VALIDATOR"]
521 | validator.validate_config(config_with_absolute_filter_paths)
522 | # print(yaml.dump(config_with_absolute_filter_paths, indent=2))
523 | errors = validator.get_errors()
524 | if len(errors) > 0:
525 | logging.debug("Config has errors")
526 | logging.debug(errors)
527 | return web.json_response(status=406, data=errors)
528 | logging.debug("Validated config, ok")
529 | return web.Response(text="OK", headers=HEADERS)
530 |
531 |
532 | async def get_wav_info(request):
533 | """
534 | Read the header of a wav file and return the info.
535 | """
536 | filename = request.query["filename"]
537 | wav_info = read_wav_header(filename)
538 | return web.json_response(wav_info, headers=HEADERS)
539 |
540 | async def store_coeffs(request):
541 | """
542 | Store a FIR coefficients file to coeff_dir.
543 | """
544 | folder = request.app["coeff_dir"]
545 | return await store_files(folder, request)
546 |
547 |
548 | async def store_configs(request):
549 | """
550 | Store a config file to config_dir.
551 | """
552 | folder = request.app["config_dir"]
553 | return await store_files(folder, request)
554 |
555 |
556 | async def get_stored_coeffs(request):
557 | """
558 | Fetch a list of coefficient files in coeff_dir.
559 | """
560 | coeff_dir = request.app["coeff_dir"]
561 | coeffs = list_of_files_in_directory(coeff_dir)
562 | return web.json_response(coeffs, headers=HEADERS)
563 |
564 |
565 | async def get_stored_configs(request):
566 | """
567 | Fetch a list of config files in config_dir.
568 | """
569 | config_dir = request.app["config_dir"]
570 | validator = request.app["VALIDATOR"]
571 | configs = list_of_files_in_directory(config_dir, title_and_desc=True, validator=validator)
572 | return web.json_response(configs, headers=HEADERS)
573 |
574 |
575 | async def delete_coeffs(request):
576 | """
577 | Delete one or several coefficient files from coeff_dir.
578 | """
579 | coeff_dir = request.app["coeff_dir"]
580 | files = await request.json()
581 | delete_files(coeff_dir, files)
582 | return web.Response(text="ok", headers=HEADERS)
583 |
584 |
585 | async def delete_configs(request):
586 | """
587 | Delete one or several config files from config_dir.
588 | """
589 | config_dir = request.app["config_dir"]
590 | files = await request.json()
591 | delete_files(config_dir, files)
592 | return web.Response(text="ok", headers=HEADERS)
593 |
594 |
595 | async def download_coeffs_zip(request):
596 | """
597 | Fetch one or several coeffcient files in a zip file.
598 | """
599 | coeff_dir = request.app["coeff_dir"]
600 | files = await request.json()
601 | zip_file = zip_of_files(coeff_dir, files)
602 | return await zip_response(request, zip_file, "coeffs.zip")
603 |
604 |
605 | async def download_configs_zip(request):
606 | """
607 | Fetch one or several config files in a zip file.
608 | """
609 | config_dir = request.app["config_dir"]
610 | files = await request.json()
611 | zip_file = zip_of_files(config_dir, files)
612 | return await zip_response(request, zip_file, "configs.zip")
613 |
614 |
615 | async def get_gui_config(request):
616 | """
617 | Get the gui configuration.
618 | """
619 | gui_config_path = request.app["gui_config_file"]
620 | if gui_config_path is None:
621 | gui_config_path = GUI_CONFIG_PATH
622 | gui_config = get_gui_config_or_defaults(gui_config_path)
623 | gui_config["coeff_dir"] = coeff_dir_relative_to_config_dir(request)
624 | gui_config["supported_capture_types"] = request.app["supported_capture_types"]
625 | gui_config["supported_playback_types"] = request.app["supported_playback_types"]
626 | gui_config["can_update_active_config"] = request.app["can_update_active_config"]
627 | logging.debug(f"GUI config: {str(gui_config)}")
628 | return web.json_response(gui_config, headers=HEADERS)
629 |
630 |
631 | async def get_defaults_for_coeffs(request):
632 | """
633 | Fetch reasonable settings for a coefficient file, based on file ending.
634 | """
635 | path = request.query["file"]
636 | absolute_path = make_absolute(path, request.app["config_dir"])
637 | defaults = defaults_for_filter(absolute_path)
638 | return web.json_response(defaults, headers=HEADERS)
639 |
640 |
641 | async def get_log_file(request):
642 | """
643 | Read and return the log file from the camilladsp process.
644 | """
645 | log_file_path = request.app["log_file"]
646 | try:
647 | with open(expanduser(log_file_path)) as log_file:
648 | text = log_file.read()
649 | return web.Response(body=text, headers=HEADERS)
650 | except OSError:
651 | logging.error("Unable to read logfile at " + log_file_path)
652 | if log_file_path:
653 | error_message = "Please configure CamillaDSP to log to: " + log_file_path
654 | else:
655 | error_message = "Please configure a valid 'log_file' path"
656 | return web.Response(body=error_message, headers=HEADERS)
657 |
658 |
659 | async def get_capture_devices(request):
660 | """
661 | Get a list of available capture devices for a backend.
662 | Return a cached list if CamillaDSP is offline.
663 | """
664 | backend = request.match_info["backend"]
665 | cdsp = request.app["CAMILLA"]
666 | try:
667 | devs = cdsp.general.list_capture_devices(backend)
668 | except IOError:
669 | logging.debug("CamillaDSP is offline, returning capture devices from cache")
670 | devs = request.app["STATUSCACHE"]["capture_devices"].get(backend, [])
671 | return web.json_response(devs, headers=HEADERS)
672 |
673 |
674 | async def get_playback_devices(request):
675 | """
676 | Get a list of available playback devices for a backend.
677 | Return a cached list if CamillaDSP is offline.
678 | """
679 | backend = request.match_info["backend"]
680 | cdsp = request.app["CAMILLA"]
681 | try:
682 | devs = cdsp.general.list_playback_devices(backend)
683 | except IOError:
684 | logging.debug("CamillaDSP is offline, returning playback devices from cache")
685 | devs = request.app["STATUSCACHE"]["playback_devices"].get(backend, [])
686 | return web.json_response(devs, headers=HEADERS)
687 |
688 |
689 | async def get_backends(request):
690 | """
691 | Get lists of available playback and capture backends.
692 | Since this can not change while CamillaDSP is running,
693 | the response is taken from the cache.
694 | """
695 | backends = request.app["STATUSCACHE"]["backends"]
696 | return web.json_response(backends, headers=HEADERS)
697 |
--------------------------------------------------------------------------------
/build/.put_statics_here:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HEnquist/camillagui-backend/0e99986a7558cc64b5ed3ff47ef7fd698eeeaa98/build/.put_statics_here
--------------------------------------------------------------------------------
/config/camillagui.yml:
--------------------------------------------------------------------------------
1 | ---
2 | camilla_host: "127.0.0.1"
3 | camilla_port: 1234
4 | bind_address: "0.0.0.0"
5 | port: 5005
6 | ssl_certificate: null
7 | ssl_private_key: null
8 | gui_config_file: null
9 | config_dir: "~/camilladsp/configs"
10 | coeff_dir: "~/camilladsp/coeffs"
11 | default_config: "~/camilladsp/default_config.yml"
12 | statefile_path: "~/camilladsp/statefile.yml"
13 | log_file: "~/camilladsp/camilladsp.log"
14 | on_set_active_config: null
15 | on_get_active_config: null
16 | supported_capture_types: null
17 | supported_playback_types: null
18 |
--------------------------------------------------------------------------------
/config/gui-config.yml:
--------------------------------------------------------------------------------
1 | ---
2 | hide_capture_samplerate: false
3 | hide_silence: false
4 | hide_capture_device: false
5 | hide_playback_device: false
6 | hide_rate_monitoring: false
7 | hide_multithreading: false
8 | apply_config_automatically: false
9 | status_update_interval: 100
10 | volume_range: 50
11 | volume_max: 0
12 | custom_shortcuts:
13 | - section: "Equalizer"
14 | description: |
15 | To use the EQ, add filters named "Bass" and "Treble" to the pipeline.
16 |
17 | Recommended settings:
18 | Bass: Biquad Lowshelf freq=85 q=0.9
19 | Treble: Biquad Highshelf freq=6500 q=0.7
20 | shortcuts:
21 | - name: "Treble (dB)"
22 | config_elements:
23 | - path: ["filters", "Treble", "parameters", "gain"]
24 | range_from: -12
25 | range_to: 12
26 | step: 0.5
27 | - name: "Bass (dB)"
28 | config_elements:
29 | - path: ["filters", "Bass", "parameters", "gain"]
30 | range_from: -12
31 | range_to: 12
32 | step: 0.5
33 | # - section: "Custom"
34 | # description: |
35 | # Demo for a few custom shortcuts.
36 | # For crossover example, add one biquad lowpass filter named "Lowpass",
37 | # and one highpass named "Highpass".
38 | #
39 | # For the crossfade and switch examples,
40 | # add two gain filters named "GainA" and "GainB".
41 | # shortcuts:
42 | # - name: "Crossover freq"
43 | # config_elements:
44 | # - path: ["filters", "Lowpass", "parameters", "freq"]
45 | # reverse: false
46 | # - path: ["filters", "Highpass", "parameters", "freq"]
47 | # reverse: false
48 | # range_from: 1000
49 | # range_to: 1500
50 | # step: 10
51 | # - name: "Crossfade"
52 | # config_elements:
53 | # - path: ["filters", "GainA", "parameters", "gain"]
54 | # reverse: false
55 | # - path: ["filters", "GainB", "parameters", "gain"]
56 | # reverse: true
57 | # range_from: -20
58 | # range_to: 0
59 | # step: 0.5
60 | # type: "number"
61 | # - name: "Switch"
62 | # config_elements:
63 | # - path: ["filters", "GainA", "parameters", "mute"]
64 | # reverse: false
65 | # - path: ["filters", "GainB", "parameters", "mute"]
66 | # reverse: true
67 | # type: "boolean"
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | from aiohttp import web
2 | import argparse
3 | import ssl
4 | import logging
5 | import sys
6 | import camilladsp
7 | from camilladsp_plot.validate_config import CamillaValidator
8 | from camilladsp_plot import VERSION as plot_version
9 |
10 | from backend.version import VERSION
11 | from backend.routes import setup_routes, setup_static_routes
12 | from backend.settings import get_config, CONFIG_PATH
13 | from backend.views import version_string
14 |
15 | LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"]
16 |
17 | #logging.info("info")
18 | #logging.debug("debug")
19 | #logging.warning("warning")
20 | #logging.error("error")
21 |
22 | def build_app(backend_config):
23 | app = web.Application(client_max_size=1024 ** 3) # set max upload file size to 1GB
24 | app["config_dir"] = backend_config["config_dir"]
25 | app["coeff_dir"] = backend_config["coeff_dir"]
26 | app["default_config"] = backend_config["default_config"]
27 | app["statefile_path"] = backend_config["statefile_path"]
28 | app["log_file"] = backend_config["log_file"]
29 | app["on_set_active_config"] = backend_config["on_set_active_config"]
30 | app["on_get_active_config"] = backend_config["on_get_active_config"]
31 | app["supported_capture_types"] = backend_config["supported_capture_types"]
32 | app["supported_playback_types"] = backend_config["supported_playback_types"]
33 | app["can_update_active_config"] = backend_config["can_update_active_config"]
34 | app["gui_config_file"] = backend_config["gui_config_file"]
35 | setup_routes(app)
36 | setup_static_routes(app)
37 |
38 | app["CAMILLA"] = camilladsp.CamillaClient(backend_config["camilla_host"], backend_config["camilla_port"])
39 | app["STATUSCACHE"] = {
40 | "backend_version": version_string(VERSION),
41 | "py_cdsp_version": version_string(app["CAMILLA"].versions.library()),
42 | "py_cdsp_plot_version": plot_version,
43 | "backends": [],
44 | "playback_devices": {},
45 | "capture_devices": {},
46 | }
47 | app["STORE"] = {
48 | "reconnect_thread": None,
49 | "cache_time": 0,
50 | }
51 |
52 | camillavalidator = CamillaValidator()
53 | if backend_config["supported_capture_types"] is not None:
54 | camillavalidator.set_supported_capture_types(backend_config["supported_capture_types"])
55 | if backend_config["supported_playback_types"] is not None:
56 | camillavalidator.set_supported_playback_types(backend_config["supported_playback_types"])
57 | app["VALIDATOR"] = camillavalidator
58 | return app
59 |
60 | def main():
61 | parser = argparse.ArgumentParser(
62 | prog="python main.py",
63 | description="Backend for the CamillaDSP web GUI")
64 | parser.add_argument("-c", "--config", help="Provide a path to a backend config file to use instead of the default", default=CONFIG_PATH)
65 | parser.add_argument("-l", "--log-level", help="Logging level", choices=LOG_LEVELS, default="WARNING")
66 | parser.add_argument("-a", "--aiohttp-log-level", help="AIOHTTP logging level", choices=LOG_LEVELS, default="WARNING")
67 |
68 | args = parser.parse_args()
69 |
70 | logging.getLogger("aiohttp").setLevel(getattr(logging, args.aiohttp_log_level))
71 | logging.getLogger("root").setLevel(getattr(logging, args.log_level))
72 |
73 | config = get_config(args.config)
74 |
75 | app = build_app(config)
76 | if config.get("ssl_certificate"):
77 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
78 | ssl_context.load_cert_chain(config["ssl_certificate"], keyfile=config.get("ssl_private_key"))
79 | else:
80 | ssl_context = None
81 | web.run_app(app, host=config["bind_address"], port=config["port"], ssl_context=ssl_context)
82 |
83 | if __name__ == "__main__":
84 | main()
85 |
--------------------------------------------------------------------------------
/release_automation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HEnquist/camillagui-backend/0e99986a7558cc64b5ed3ff47ef7fd698eeeaa98/release_automation/__init__.py
--------------------------------------------------------------------------------
/release_automation/render_env_files.py:
--------------------------------------------------------------------------------
1 | import yaml
2 | import os
3 |
4 | from jinja2 import Environment, FileSystemLoader
5 |
6 | from backend.version import VERSION
7 |
8 | script_dir = os.path.dirname(__file__)
9 |
10 | with open(os.path.join(script_dir, "versions.yml")) as f:
11 | versions = yaml.safe_load(f)
12 |
13 | versions["backend_version"] = ".".join(str(v) for v in VERSION)
14 |
15 | environment = Environment(loader=FileSystemLoader(os.path.join(script_dir, "templates/")))
16 |
17 | filenames = [
18 | "requirements.txt",
19 | "cdsp_conda.yml",
20 | "pyproject.toml",
21 | ]
22 |
23 | for filename in filenames:
24 | t = environment.get_template(filename + ".j2")
25 |
26 | # render and write
27 | rendered = t.render(versions)
28 | with open(filename, mode="w", encoding="utf-8") as f:
29 | f.write(rendered)
30 |
--------------------------------------------------------------------------------
/release_automation/templates/cdsp_conda.yml.j2:
--------------------------------------------------------------------------------
1 | {# Template for conda environment file -#}
2 | ---
3 | name: camillagui
4 | channels:
5 | - conda-forge
6 | dependencies:
7 | - pip
8 | - aiohttp
9 | - jsonschema
10 | - pyyaml
11 | - pip:
12 | - "git+https://github.com/HEnquist/pycamilladsp.git@{{ pycamilladsp_tag }}"
13 | - "camilladsp-plot[plot]@git+https://github.com/HEnquist/pycamilladsp-plot.git@{{ pycamilladsp_plot_tag }}"
14 |
--------------------------------------------------------------------------------
/release_automation/templates/pyproject.toml.j2:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "camillagui-backend"
3 | version = "{{ backend_version }}"
4 | description = "Backend server for CamillaGUI"
5 | authors = ["Henrik Enquist "]
6 | license = "GPLv3"
7 | readme = "README.md"
8 |
9 | [tool.poetry.dependencies]
10 | python = "^3.12"
11 | websocket-client = "^1.6.4"
12 | jsonschema = "^4.20.0"
13 | aiohttp = "^3.9.0"
14 | numpy = "^1.26.0"
15 | PyYAML = "^6.0.0"
16 | camilladsp = {git = "https://github.com/HEnquist/pycamilladsp.git", rev = "{{ pycamilladsp_tag }}"}
17 | camilladsp-plot = {git = "https://github.com/HEnquist/pycamilladsp-plot.git", rev = "{{ pycamilladsp_plot_tag }}"}
18 |
19 | [build-system]
20 | requires = ["poetry-core"]
21 | build-backend = "poetry.core.masonry.api"
--------------------------------------------------------------------------------
/release_automation/templates/requirements.txt.j2:
--------------------------------------------------------------------------------
1 | {# Template for pip requirements file -#}
2 | aiohttp
3 | jsonschema
4 | PyYAML
5 | git+https://github.com/HEnquist/pycamilladsp.git@{{ pycamilladsp_tag }}
6 | camilladsp-plot[plot]@git+https://github.com/HEnquist/pycamilladsp-plot.git@{{ pycamilladsp_plot_tag }}
7 |
--------------------------------------------------------------------------------
/release_automation/versions.yml:
--------------------------------------------------------------------------------
1 | ---
2 | camillagui_tag: v3.0.3
3 | pycamilladsp_tag: v3.0.0
4 | pycamilladsp_plot_tag: v3.0.2
5 |
--------------------------------------------------------------------------------
/tests/test_basic_api.py:
--------------------------------------------------------------------------------
1 | import json
2 | import pytest
3 | from unittest.mock import MagicMock, patch
4 | import pytest
5 | from aiohttp import web, FormData
6 | import os
7 | import yaml
8 | import random
9 | import string
10 |
11 | import main
12 | from backend import views
13 | import camilladsp
14 |
15 | TESTFILE_DIR = os.path.join(os.path.dirname(__file__), "testfiles")
16 | SAMPLE_CONFIG_PATH = os.path.join(TESTFILE_DIR, "config.yml")
17 | STATEFILE_PATH = os.path.join(TESTFILE_DIR, "statefile.yml")
18 | STATEFILE_TEMPLATE_PATH = os.path.join(TESTFILE_DIR, "statefile_template.yml")
19 | LOGFILE_PATH = os.path.join(TESTFILE_DIR, "log.txt")
20 | SAMPLE_CONFIG = yaml.safe_load(open(SAMPLE_CONFIG_PATH))
21 | GUI_CONFIG_PATH = os.path.join(TESTFILE_DIR, "gui_config.yml")
22 |
23 |
24 | @pytest.fixture
25 | def statefile():
26 | statefile_data = yaml.safe_load(open(STATEFILE_TEMPLATE_PATH))
27 | statefile_data["config_path"] = os.path.join(
28 | TESTFILE_DIR, statefile_data["config_path"]
29 | )
30 | with open(STATEFILE_PATH, "w") as f:
31 | yaml.dump(statefile_data, f)
32 |
33 |
34 | server_config = {
35 | "camilla_host": "127.0.0.1",
36 | "camilla_port": 1234,
37 | "bind_address": "0.0.0.0",
38 | "port": 5005,
39 | "config_dir": TESTFILE_DIR,
40 | "coeff_dir": TESTFILE_DIR,
41 | "default_config": SAMPLE_CONFIG_PATH,
42 | "statefile_path": STATEFILE_PATH,
43 | "log_file": LOGFILE_PATH,
44 | "gui_config_file": GUI_CONFIG_PATH,
45 | "on_set_active_config": None,
46 | "on_get_active_config": None,
47 | "supported_capture_types": None,
48 | "supported_playback_types": None,
49 | "can_update_active_config": True,
50 | }
51 |
52 |
53 | @pytest.fixture
54 | def mock_request(mock_app):
55 | request = MagicMock
56 | request.app = mock_app
57 | yield request
58 |
59 |
60 | @pytest.fixture
61 | def mock_camillaclient(statefile):
62 | client = MagicMock()
63 | client_constructor = MagicMock(return_value=client)
64 | client_constructor._client = client
65 | client.volume = MagicMock()
66 | client.volume.main_volume = MagicMock(return_value=-20.0)
67 | client.volume.main_mute = MagicMock(return_value=False)
68 | client.levels = MagicMock
69 | client.levels.capture_peak = MagicMock(return_value=[-2.0, -3.0])
70 | client.levels.playback_peak = MagicMock(return_value=[-2.5, -3.5])
71 | client.levels.levels = MagicMock(
72 | return_value={
73 | "capture_rms": [-5.0, -6.0],
74 | "capture_peak": [-2.0, -3.0],
75 | "playback_rms": [-7.0, -8.0],
76 | "playback_peak": [-3.0, -4.0],
77 | }
78 | )
79 | client.rate = MagicMock()
80 | client.rate.capture = MagicMock(return_value=44100)
81 | client.general = MagicMock()
82 | client.general.state = MagicMock(
83 | return_value=camilladsp.ProcessingState.RUNNING
84 | )
85 | client.general.list_capture_devices = MagicMock(
86 | return_value=[["hw:Aaaa,0,0", "Dev A"], ["hw:Bbbb,0,0", "Dev B"]]
87 | )
88 | client.general.list_playback_devices = MagicMock(
89 | return_value=[["hw:Cccc,0,0", "Dev C"], ["hw:Dddd,0,0", "Dev D"]]
90 | )
91 | client.general.supported_device_types = MagicMock(return_value=["Alsa", "Wasapi"])
92 | client.status = MagicMock()
93 | client.status.rate_adjust = MagicMock(return_value=1.01)
94 | client.status.buffer_level = MagicMock(return_value=1234)
95 | client.status.clipped_samples = MagicMock(return_value=12)
96 | client.status.processing_load = MagicMock(return_value=0.5)
97 | client.config = MagicMock()
98 | client.config.active = MagicMock(return_value=SAMPLE_CONFIG)
99 | client.config.file_path = MagicMock(return_value=SAMPLE_CONFIG_PATH)
100 | client.versions = MagicMock()
101 | client.versions.library = MagicMock(return_value="1.2.3")
102 | yield client_constructor
103 |
104 |
105 | @pytest.fixture
106 | def mock_app(mock_camillaclient):
107 | with patch("camilladsp.CamillaClient", mock_camillaclient):
108 | app = main.build_app(server_config)
109 | yield app
110 |
111 |
112 | @pytest.fixture
113 | def mock_offline_app(mock_camillaclient):
114 | mock_camillaclient._client.config.file_path = MagicMock(return_value=None)
115 | mock_camillaclient._client.general.state = MagicMock(
116 | side_effect=camilladsp.CamillaError
117 | )
118 | with patch("camilladsp.CamillaClient", mock_camillaclient):
119 | app = main.build_app(server_config)
120 | yield app
121 |
122 |
123 | @pytest.fixture
124 | def server(event_loop, aiohttp_client, mock_app):
125 | return event_loop.run_until_complete(aiohttp_client(mock_app))
126 |
127 |
128 | @pytest.fixture
129 | def offline_server(event_loop, aiohttp_client, mock_offline_app):
130 | return event_loop.run_until_complete(aiohttp_client(mock_offline_app))
131 |
132 |
133 | @pytest.mark.asyncio
134 | async def test_read_volume(mock_request):
135 | mock_request.match_info = {"name": "volume"}
136 | reply = await views.get_param(mock_request)
137 | assert reply.body == "-20.0"
138 |
139 |
140 | @pytest.mark.asyncio
141 | async def test_read_peaks(mock_request):
142 | mock_request.match_info = {"name": "capturesignalpeak"}
143 | reply = await views.get_list_param(mock_request)
144 | assert json.loads(reply.body) == [-2.0, -3.0]
145 |
146 |
147 | @pytest.mark.asyncio
148 | async def test_read_volume(server):
149 | resp = await server.get("/api/getparam/volume")
150 | assert resp.status == 200
151 | assert await resp.text() == "-20.0"
152 |
153 |
154 | @pytest.mark.asyncio
155 | async def test_read_peaks(server):
156 | resp = await server.get("/api/getlistparam/capturesignalpeak")
157 | assert resp.status == 200
158 | assert await resp.json() == [-2.0, -3.0]
159 |
160 |
161 | @pytest.mark.asyncio
162 | async def test_read_status(server):
163 | resp = await server.get("/api/status")
164 | assert resp.status == 200
165 | response = await resp.json()
166 | assert response["cdsp_status"] == "RUNNING"
167 |
168 |
169 | @pytest.mark.parametrize(
170 | "endpoint, parameters",
171 | [
172 | ("/api/status", None),
173 | ("/api/getparam/mute", None),
174 | ("/api/getlistparam/playbacksignalpeak", None),
175 | ("/api/getconfig", None),
176 | ("/api/getactiveconfigfile", None),
177 | ("/api/getdefaultconfigfile", None),
178 | ("/api/storedconfigs", None),
179 | ("/api/storedcoeffs", None),
180 | ("/api/defaultsforcoeffs", {"file": "test.wav"}),
181 | ("/api/guiconfig", None),
182 | ("/api/getconfigfile", {"name": "config.yml"}),
183 | ("/api/logfile", None),
184 | ("/api/capturedevices/alsa", None),
185 | ("/api/playbackdevices/alsa", None),
186 | ("/api/backends", None),
187 | ],
188 | )
189 | @pytest.mark.asyncio
190 | async def test_all_get_endpoints_ok(server, endpoint, parameters):
191 | if parameters:
192 | resp = await server.get(endpoint, params=parameters)
193 | else:
194 | resp = await server.get(endpoint)
195 | assert resp.status == 200
196 |
197 |
198 | @pytest.mark.parametrize(
199 | "upload, delete, getfile",
200 | [
201 | ("/api/uploadconfigs", "/api/deleteconfigs", "/config/"),
202 | ("/api/uploadcoeffs", "/api/deletecoeffs", "/coeff/"),
203 | ],
204 | )
205 | @pytest.mark.asyncio
206 | async def test_upload_and_delete(server, upload, delete, getfile):
207 | filename = "".join(random.choice(string.ascii_lowercase) for i in range(10))
208 | filedata = "".join(random.choice(string.ascii_lowercase) for i in range(10))
209 |
210 | # try to get a file that does not exist
211 | resp = await server.get(getfile + filename)
212 | assert resp.status == 404
213 |
214 | # generate and upload a file
215 | data = FormData()
216 | data.add_field("file0", filedata.encode(), filename=filename)
217 | resp = await server.post(upload, data=data)
218 | assert resp.status == 200
219 |
220 | # fetch the file, check the content
221 | resp = await server.get(getfile + filename)
222 | assert resp.status == 200
223 | response_data = await resp.read()
224 | assert response_data == filedata.encode()
225 |
226 | # delete the file
227 | resp = await server.post(delete, json=[filename])
228 | assert resp.status == 200
229 |
230 | # try to download the deleted file
231 | resp = await server.get(getfile + filename)
232 | assert resp.status == 404
233 |
234 |
235 | @pytest.mark.asyncio
236 | async def test_active_config_online(server):
237 | resp = await server.get("/api/getactiveconfigfile")
238 | assert resp.status == 200
239 | content = await resp.json()
240 | print(content)
241 | assert content["configFileName"] == "config.yml"
242 | assert content["config"]["devices"]["samplerate"] == 44100
243 |
244 |
245 | @pytest.mark.asyncio
246 | async def test_active_config_offline(offline_server):
247 | resp = await offline_server.get("/api/getactiveconfigfile")
248 | assert resp.status == 200
249 | content = await resp.json()
250 | print(content)
251 | assert content["configFileName"] == "config2.yml"
252 | assert content["config"]["devices"]["samplerate"] == 48000
253 |
254 |
255 | @pytest.mark.asyncio
256 | async def test_translate_eqapo(server):
257 | from test_eqapo_config_import import EXAMPLE
258 |
259 | resp = await server.post("/api/eqapotojson?channels=2", data=EXAMPLE)
260 | assert resp.status == 200
261 | content = await resp.json()
262 | assert "filters" in content
263 |
264 |
265 | @pytest.mark.asyncio
266 | async def test_translate_eqapo_bad(server):
267 | resp = await server.post("/api/eqapotojson", data="blank")
268 | assert resp.status == 400
269 |
270 |
271 | @pytest.mark.asyncio
272 | async def test_translate_convolver(server):
273 | resp = await server.post("/api/convolvertojson", data="96000 1 2 0\n0\n0")
274 | assert resp.status == 200
275 | content = await resp.json()
276 | assert "devices" in content
277 | assert content["devices"]["samplerate"] == 96000
278 |
--------------------------------------------------------------------------------
/tests/test_convolver_config_import.py:
--------------------------------------------------------------------------------
1 | from textwrap import dedent
2 | from backend.convolver_config_import import (
3 | ConvolverConfig,
4 | filename_of_path,
5 | channels_factors_and_inversions_as_list,
6 | )
7 |
8 |
9 | def clean_multi_line_string(multiline_text: str):
10 | """
11 | :param multiline_text:
12 | :return: the text without the first blank line and indentation
13 | """
14 | return dedent(multiline_text.lstrip("\n"))
15 |
16 |
17 | def test_filename_of_path():
18 | assert "File.wav" == filename_of_path("File.wav")
19 | assert "File.wav" == filename_of_path("/some/path/File.wav")
20 | assert "File.wav" == filename_of_path("C:\\some\\path\\File.wav")
21 |
22 |
23 | def test_channels_factors_and_inversions_as_list():
24 | assert channels_factors_and_inversions_as_list("0.0 1.1 -9.9") == [
25 | (0, 1.0, False),
26 | (1, 0.1, False),
27 | (9, 0.9, True),
28 | ]
29 | # Straight inversion
30 | # Note, the Convolver documentation says to use
31 | # -0.99999 and not -0.0 for this.
32 | assert channels_factors_and_inversions_as_list("-0.0 -0.99999") == [
33 | (0, 1.0, True),
34 | (0, 0.99999, True),
35 | ]
36 |
37 |
38 | def test_samplerate_is_imported():
39 | convolver_config = clean_multi_line_string(
40 | """
41 | 96000 1 2 0
42 | 0
43 | 0
44 | """
45 | )
46 | conf = ConvolverConfig(convolver_config).to_object()
47 | assert conf["devices"] == {"samplerate": 96000}
48 |
49 |
50 | def test_delays_and_mixers_are_imported():
51 | convolver_config = clean_multi_line_string(
52 | """
53 | 96000 2 3 0
54 | 3
55 | 0 4
56 | """
57 | )
58 | expected_filters = {
59 | "Delay3": {
60 | "type": "Delay",
61 | "parameters": {"delay": 3, "unit": "ms", "subsample": False},
62 | },
63 | "Delay4": {
64 | "type": "Delay",
65 | "parameters": {"delay": 4, "unit": "ms", "subsample": False},
66 | },
67 | }
68 | expected_pipeline = [
69 | {
70 | "type": "Filter",
71 | "channels": [0],
72 | "names": ["Delay3"],
73 | "bypassed": None,
74 | "description": None,
75 | },
76 | {"type": "Mixer", "name": "Mixer in", "description": None},
77 | {"type": "Mixer", "name": "Mixer out", "description": None},
78 | {
79 | "type": "Filter",
80 | "channels": [1],
81 | "names": ["Delay4"],
82 | "bypassed": None,
83 | "description": None,
84 | },
85 | ]
86 |
87 | conf = ConvolverConfig(convolver_config).to_object()
88 |
89 | assert conf["filters"] == expected_filters
90 | assert conf["mixers"]["Mixer in"]["channels"] == {"in": 2, "out": 1}
91 | assert conf["mixers"]["Mixer out"]["channels"] == {"in": 1, "out": 3}
92 | assert conf["pipeline"] == expected_pipeline
93 |
94 |
95 | def test_simple_impulse_response():
96 | convolver_config = clean_multi_line_string(
97 | """
98 | 0 1 1 0
99 | 0
100 | 0
101 | IR.wav
102 | 0
103 | 0.0
104 | 0.0
105 | """
106 | )
107 |
108 | expected_filters = {
109 | "IR.wav-0": {
110 | "type": "Conv",
111 | "parameters": {"type": "Wav", "filename": "IR.wav", "channel": 0},
112 | }
113 | }
114 | expected_pipeline = [
115 | {"type": "Mixer", "name": "Mixer in", "description": None},
116 | {
117 | "type": "Filter",
118 | "channels": [0],
119 | "names": ["IR.wav-0"],
120 | "bypassed": None,
121 | "description": None,
122 | },
123 | {"type": "Mixer", "name": "Mixer out", "description": None},
124 | ]
125 |
126 | conf = ConvolverConfig(convolver_config).to_object()
127 | assert conf["pipeline"] == expected_pipeline
128 | assert conf["filters"] == expected_filters
129 |
130 |
131 | def test_path_is_ignored_for_impulse_response_files():
132 | convolver_config = clean_multi_line_string(
133 | """
134 | 0 1 1 0
135 | 0
136 | 0
137 | IR1.wav
138 | 0
139 | 0.0
140 | 0.0
141 | C:\\any/path/IR2.wav
142 | 0
143 | 0.0
144 | 0.0
145 | /some/other/path/IR3.wav
146 | 0
147 | 0.0
148 | 0.0
149 | """
150 | )
151 | conf = ConvolverConfig(convolver_config).to_object()
152 | assert conf["filters"]["IR1.wav-0"]["parameters"]["filename"] == "IR1.wav"
153 | assert conf["filters"]["IR2.wav-0"]["parameters"]["filename"] == "IR2.wav"
154 | assert conf["filters"]["IR3.wav-0"]["parameters"]["filename"] == "IR3.wav"
155 |
156 |
157 | def test_wav_file_with_multiple_impulse_responses():
158 | convolver_config = clean_multi_line_string(
159 | """
160 | 0 1 1 0
161 | 0
162 | 0
163 | IR.wav
164 | 0
165 | 0.0
166 | 0.0
167 | IR.wav
168 | 1
169 | 0.0
170 | 0.0
171 | """
172 | )
173 | conf = ConvolverConfig(convolver_config).to_object()
174 | assert conf["filters"]["IR.wav-0"]["parameters"]["channel"] == 0
175 | assert conf["filters"]["IR.wav-1"]["parameters"]["channel"] == 1
176 |
177 |
178 | def test_impulse_responses_are_mapped_to_correct_channels():
179 | convolver_config = clean_multi_line_string(
180 | """
181 | 0 1 1 0
182 | 0
183 | 0
184 | IR1.wav
185 | 0
186 | 0.0
187 | 0.0
188 | IR2.wav
189 | 0
190 | 0.0
191 | 0.0
192 | """
193 | )
194 |
195 | expected = [
196 | {"type": "Mixer", "name": "Mixer in", "description": None},
197 | {
198 | "type": "Filter",
199 | "channels": [0],
200 | "names": ["IR1.wav-0"],
201 | "bypassed": None,
202 | "description": None,
203 | },
204 | {
205 | "type": "Filter",
206 | "channels": [1],
207 | "names": ["IR2.wav-0"],
208 | "bypassed": None,
209 | "description": None,
210 | },
211 | {"type": "Mixer", "name": "Mixer out", "description": None},
212 | ]
213 |
214 | conf = ConvolverConfig(convolver_config).to_object()
215 | result = conf["pipeline"]
216 | assert result == expected
217 |
218 |
219 | def test_impulse_response_with_input_scaling():
220 | convolver_config = clean_multi_line_string(
221 | """
222 | 0 2 2 0
223 | 0 0
224 | 0 0
225 | IR.wav
226 | 0
227 | 0.0 1.1
228 | 0.0
229 | IR.wav
230 | 1
231 | 0.2 1.3
232 | 0.0
233 | IR.wav
234 | 2
235 | -1.5 -0.4
236 | 0.0
237 | """
238 | )
239 | expected = {
240 | "channels": {"in": 2, "out": 3},
241 | "mapping": [
242 | {
243 | "dest": 0,
244 | "sources": [
245 | {
246 | "channel": 0,
247 | "gain": 1.0,
248 | "scale": "linear",
249 | "inverted": False,
250 | },
251 | {
252 | "channel": 1,
253 | "gain": 0.1,
254 | "scale": "linear",
255 | "inverted": False,
256 | },
257 | ],
258 | },
259 | {
260 | "dest": 1,
261 | "sources": [
262 | {
263 | "channel": 0,
264 | "gain": 0.2,
265 | "scale": "linear",
266 | "inverted": False,
267 | },
268 | {
269 | "channel": 1,
270 | "gain": 0.3,
271 | "scale": "linear",
272 | "inverted": False,
273 | },
274 | ],
275 | },
276 | {
277 | "dest": 2,
278 | "sources": [
279 | {
280 | "channel": 1,
281 | "gain": 0.5,
282 | "scale": "linear",
283 | "inverted": True,
284 | },
285 | {
286 | "channel": 0,
287 | "gain": 0.4,
288 | "scale": "linear",
289 | "inverted": True,
290 | },
291 | ],
292 | },
293 | ],
294 | }
295 | conf = ConvolverConfig(convolver_config).to_object()
296 | result = conf["mixers"]["Mixer in"]
297 | assert result == expected
298 |
299 |
300 | def test_impulse_response_with_output_scaling():
301 | convolver_config = clean_multi_line_string(
302 | """
303 | 0 2 2 0
304 | 0 0
305 | 0 0
306 | IR.wav
307 | 0
308 | 0.0
309 | 0.0 1.1
310 | IR.wav
311 | 1
312 | 0.0
313 | 0.2 1.3
314 | IR.wav
315 | 2
316 | 0.0
317 | -1.5 -0.4
318 | """
319 | )
320 | expected_mixer = {
321 | "channels": {"in": 3, "out": 2},
322 | "mapping": [
323 | {
324 | "dest": 0,
325 | "sources": [
326 | {
327 | "channel": 0,
328 | "gain": 1.0,
329 | "scale": "linear",
330 | "inverted": False,
331 | },
332 | {
333 | "channel": 1,
334 | "gain": 0.2,
335 | "scale": "linear",
336 | "inverted": False,
337 | },
338 | {
339 | "channel": 2,
340 | "gain": 0.4,
341 | "scale": "linear",
342 | "inverted": True,
343 | },
344 | ],
345 | },
346 | {
347 | "dest": 1,
348 | "sources": [
349 | {
350 | "channel": 0,
351 | "gain": 0.1,
352 | "scale": "linear",
353 | "inverted": False,
354 | },
355 | {
356 | "channel": 1,
357 | "gain": 0.3,
358 | "scale": "linear",
359 | "inverted": False,
360 | },
361 | {
362 | "channel": 2,
363 | "gain": 0.5,
364 | "scale": "linear",
365 | "inverted": True,
366 | },
367 | ],
368 | },
369 | ],
370 | }
371 |
372 | conf = ConvolverConfig(convolver_config).to_object()
373 | assert conf["mixers"]["Mixer out"] == expected_mixer
374 |
--------------------------------------------------------------------------------
/tests/test_eqapo_config_import.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from backend.eqapo_config_import import EqAPO
4 |
5 | EXAMPLE = """
6 | Device: High Definition Audio Device Speakers; Benchmark
7 | #All lines below will only be applied to the specified device and the benchmark application
8 | Preamp: -6 db
9 | Include: example.txt
10 | Filter 1: ON PK Fc 50 Hz Gain -3.0 dB Q 10.00
11 | Filter 2: ON PEQ Fc 100 Hz Gain 1.0 dB BW Oct 0.167
12 |
13 | Channel: L
14 | #Additional preamp for left channel
15 | Preamp: -5 dB
16 | #Filters only for left channel
17 | Include: demo.txt
18 | Filter 1: ON LS Fc 300 Hz Gain 5.0 dB
19 |
20 | Channel: 2 C
21 | #Filters for second(right) and center channel
22 | Filter 1: ON HP Fc 30 Hz
23 | Filter 2: ON LPQ Fc 10000 Hz Q 0.400
24 |
25 | Device: Microphone
26 | #From here, the lines only apply to microphone devices
27 | Filter: ON NO Fc 50 Hz
28 | """
29 |
30 |
31 | @pytest.fixture
32 | def eqapo():
33 | converter = EqAPO(EXAMPLE, 2)
34 | yield converter
35 |
36 |
37 | PK_EQAPO = "Filter 1: ON PK Fc 50 Hz Gain -3.0 dB Q 10.00"
38 | PK_CDSP = {"freq": 50.0, "gain": -3.0, "q": 10.0, "type": "Peaking"}
39 |
40 | PEQ_EQAPO = "Filter 2: ON PEQ Fc 100 Hz Gain 1.0 dB BW Oct 0.167"
41 | PEQ_CDSP = {"freq": 100.0, "gain": 1.0, "bandwidth": 0.167, "type": "Peaking"}
42 |
43 |
44 | @pytest.mark.parametrize(
45 | "filterline, expected_params",
46 | [(PK_EQAPO, PK_CDSP), (PEQ_EQAPO, PEQ_CDSP)],
47 | )
48 | def test_single_filter(eqapo, filterline, expected_params):
49 | eqapo.parse_line(filterline)
50 | name, filt = next(iter(eqapo.filters.items()))
51 | assert filt["parameters"] == expected_params
52 | assert name == "Filter_1"
53 |
54 |
55 | SIMPLE_CONV_EQAPO = """
56 | Channel: L
57 | Convolution: L.wav
58 | Channel: R
59 | Convolution: R.wav
60 | """
61 |
62 | SIMPLE_CONV_CDSP = {
63 | "filters": {
64 | "Convolution_1": {
65 | "type": "Conv",
66 | "parameters": {"filename": "L.wav", "type": "wav"},
67 | "description": "Convolution: L.wav",
68 | },
69 | "Convolution_2": {
70 | "type": "Conv",
71 | "parameters": {"filename": "R.wav", "type": "wav"},
72 | "description": "Convolution: R.wav",
73 | },
74 | },
75 | "mixers": {},
76 | "pipeline": [
77 | {
78 | "type": "Filter",
79 | "names": ["Convolution_1"],
80 | "description": "Channel: L",
81 | "channels": [0],
82 | },
83 | {
84 | "type": "Filter",
85 | "names": ["Convolution_2"],
86 | "description": "Channel: R",
87 | "channels": [1],
88 | },
89 | ],
90 | }
91 |
92 |
93 | def test_simple_conv():
94 | converter = EqAPO(SIMPLE_CONV_EQAPO, 2)
95 | converter.translate_file()
96 | conf = converter.build_config()
97 | assert conf == SIMPLE_CONV_CDSP
98 |
99 |
100 | CROSSOVER_EQAPO = """
101 | Copy: RL=L RR=R
102 | Channel: L R
103 | Filter 1: ON LP Fc 2000 Hz
104 | Channel: RL RR
105 | Filter 2: ON HP Fc 2000 Hz
106 | """
107 |
108 | CROSSOVER_CDSP = {
109 | "filters": {
110 | "Filter_1": {
111 | "type": "Biquad",
112 | "parameters": {"type": "Lowpass", "freq": 2000.0},
113 | "description": "Filter 1: ON LP Fc 2000 Hz",
114 | },
115 | "Filter_2": {
116 | "type": "Biquad",
117 | "parameters": {"type": "Highpass", "freq": 2000.0},
118 | "description": "Filter 2: ON HP Fc 2000 Hz",
119 | },
120 | },
121 | "mixers": {
122 | "Copy_1": {
123 | "channels": {"in": 4, "out": 4},
124 | "mapping": [
125 | {
126 | "dest": 2,
127 | "mute": False,
128 | "sources": [
129 | {"channel": 0, "gain": 0, "inverted": False, "scale": "dB"}
130 | ],
131 | },
132 | {
133 | "dest": 3,
134 | "mute": False,
135 | "sources": [
136 | {"channel": 1, "gain": 0, "inverted": False, "scale": "dB"}
137 | ],
138 | },
139 | {
140 | "dest": 0,
141 | "mute": False,
142 | "sources": [
143 | {
144 | "channel": 0,
145 | "gain": 0.0,
146 | "inverted": False,
147 | "scale": "dB",
148 | }
149 | ],
150 | },
151 | {
152 | "dest": 1,
153 | "mute": False,
154 | "sources": [
155 | {
156 | "channel": 1,
157 | "gain": 0.0,
158 | "inverted": False,
159 | "scale": "dB",
160 | }
161 | ],
162 | },
163 | ],
164 | "description": "Copy: RL=L RR=R",
165 | }
166 | },
167 | "pipeline": [
168 | {"type": "Mixer", "name": "Copy_1"},
169 | {
170 | "type": "Filter",
171 | "names": ["Filter_1"],
172 | "description": "Channel: L R",
173 | "channels": [0, 1],
174 | },
175 | {
176 | "type": "Filter",
177 | "names": ["Filter_2"],
178 | "description": "Channel: RL RR",
179 | "channels": [2, 3],
180 | },
181 | ],
182 | }
183 |
184 |
185 | def test_crossover():
186 | converter = EqAPO(CROSSOVER_EQAPO, 4)
187 | converter.translate_file()
188 | conf = converter.build_config()
189 | assert conf == CROSSOVER_CDSP
190 |
--------------------------------------------------------------------------------
/tests/test_filters.py:
--------------------------------------------------------------------------------
1 | from backend.filters import filter_plot_options, pipeline_step_plot_options
2 |
3 |
4 | def test_filter_plot_options_with_samplerate():
5 | result = filter_plot_options(
6 | ["filter_44100_2", "filter_44100_8", "filter_48000_2", "filter_48000_8"],
7 | "filter_$samplerate$_2",
8 | )
9 | expected = [
10 | {"name": "filter_44100_2", "samplerate": 44100},
11 | {"name": "filter_48000_2", "samplerate": 48000},
12 | ]
13 | assert result == expected
14 |
15 |
16 | def test_filter_plot_options_with_channels():
17 | result = filter_plot_options(
18 | ["filter_44100_2", "filter_44100_8", "filter_48000_2", "filter_48000_8"],
19 | "filter_44100_$channels$",
20 | )
21 | expected = [
22 | {"name": "filter_44100_2", "channels": 2},
23 | {"name": "filter_44100_8", "channels": 8},
24 | ]
25 | assert result == expected
26 |
27 |
28 | def test_filter_plot_options_with_samplerate_and_channels():
29 | result1 = filter_plot_options(
30 | ["filter_44100_2", "filter_44100_8", "filter_48000_2", "filter_48000_8"],
31 | "filter_$samplerate$_$channels$",
32 | )
33 | expected1 = [
34 | {"name": "filter_44100_2", "samplerate": 44100, "channels": 2},
35 | {"name": "filter_44100_8", "samplerate": 44100, "channels": 8},
36 | {"name": "filter_48000_2", "samplerate": 48000, "channels": 2},
37 | {"name": "filter_48000_8", "samplerate": 48000, "channels": 8},
38 | ]
39 | assert result1 == expected1
40 |
41 | result2 = filter_plot_options(
42 | ["filter_2_44100", "filter_8_44100", "filter_2_48000", "filter_8_48000"],
43 | "filter_$channels$_$samplerate$",
44 | )
45 | expected2 = [
46 | {"name": "filter_2_44100", "samplerate": 44100, "channels": 2},
47 | {"name": "filter_8_44100", "samplerate": 44100, "channels": 8},
48 | {"name": "filter_2_48000", "samplerate": 48000, "channels": 2},
49 | {"name": "filter_8_48000", "samplerate": 48000, "channels": 8},
50 | ]
51 | assert result2 == expected2
52 |
53 |
54 | def test_filter_plot_options_without_samplerate_and_channels():
55 | result = filter_plot_options(
56 | ["filter_44100_2", "filter_44100_8", "filter_48000_2", "filter_48000_8"],
57 | "filter_44100_2",
58 | )
59 | expected = [{"name": "filter_44100_2"}]
60 | assert result == expected
61 |
62 |
63 | def test_filter_plot_options_handles_filenames_with_brackets():
64 | expected = filter_plot_options(
65 | [
66 | "filter_((44100)_(2))",
67 | "filter_((44100)_(8))",
68 | "filter_((48000)_(2))",
69 | "filter_((48000)_(8))",
70 | ],
71 | "filter_(($samplerate$)_($channels$))",
72 | )
73 | result = [
74 | {"name": "filter_((44100)_(2))", "samplerate": 44100, "channels": 2},
75 | {"name": "filter_((44100)_(8))", "samplerate": 44100, "channels": 8},
76 | {"name": "filter_((48000)_(2))", "samplerate": 48000, "channels": 2},
77 | {"name": "filter_((48000)_(8))", "samplerate": 48000, "channels": 8},
78 | ]
79 | assert result == expected
80 |
81 |
82 | def test_pipeline_step_plot_options_for_only_one_samplerate_and_channel_option():
83 | config = {
84 | "devices": {"samplerate": 44100, "capture": {"channels": 2}},
85 | "filters": {
86 | "Filter1": {
87 | "type": "Conv",
88 | "parameters": {"type": "Raw", "filename": "../coeffs/filter-44100-2"},
89 | },
90 | "Filter2": {
91 | "type": "Conv",
92 | "parameters": {
93 | "type": "Wav",
94 | "filename": "../coeffs/filter-$samplerate$-$channels$",
95 | },
96 | },
97 | "irrelevantFilter": {"type": "something else", "parameters": {}},
98 | },
99 | "pipeline": [
100 | {
101 | "channel": 0,
102 | "type": "Filter",
103 | "names": ["Filter1", "Filter2", "irrelevantFilter"],
104 | }
105 | ],
106 | }
107 | filter_file_names = [
108 | "filter-44100-2",
109 | "filter-44100-8",
110 | "filter-48000-2",
111 | "filter-48000-8",
112 | ]
113 | result = pipeline_step_plot_options(filter_file_names, config, 0)
114 | expected = [{"name": "44100 Hz - 2 Channels", "samplerate": 44100, "channels": 2}]
115 | assert result == expected
116 |
117 |
118 | def test_pipeline_step_plot_options_for_many_samplerate_and_channel_options():
119 | config = {
120 | "devices": {"samplerate": 44100, "capture": {"channels": 2}},
121 | "filters": {
122 | "Filter1": {
123 | "type": "Conv",
124 | "parameters": {
125 | "type": "Raw",
126 | "filename": "../coeffs/filter-$samplerate$-$channels$",
127 | },
128 | },
129 | "Filter2": {
130 | "type": "Conv",
131 | "parameters": {
132 | "type": "Raw",
133 | "filename": "../coeffs/filter-$samplerate$-$channels$",
134 | },
135 | },
136 | },
137 | "pipeline": [{"channel": 0, "type": "Filter", "names": ["Filter1", "Filter2"]}],
138 | }
139 | filter_file_names = [
140 | "filter-44100-2",
141 | "filter-44100-8",
142 | "filter-48000-2",
143 | "filter-48000-8",
144 | ]
145 | result = pipeline_step_plot_options(filter_file_names, config, 0)
146 | expected = [
147 | {"name": "44100 Hz - 2 Channels", "samplerate": 44100, "channels": 2},
148 | {"name": "44100 Hz - 8 Channels", "samplerate": 44100, "channels": 8},
149 | {"name": "48000 Hz - 2 Channels", "samplerate": 48000, "channels": 2},
150 | {"name": "48000 Hz - 8 Channels", "samplerate": 48000, "channels": 8},
151 | ]
152 | assert result == expected
153 |
--------------------------------------------------------------------------------
/tests/test_legacy_config.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from backend.legacy_config_import import (
4 | _modify_devices,
5 | _remove_volume_filters,
6 | _modify_loundness_filters,
7 | _modify_dither,
8 | _modify_pipeline_filter_steps,
9 | migrate_legacy_config,
10 | )
11 | from camilladsp_plot.validate_config import CamillaValidator
12 |
13 |
14 | @pytest.fixture
15 | def basic_config():
16 | # Config for camilladsp v1.0.x
17 | config = {
18 | "devices": {
19 | "samplerate": 96000,
20 | "chunksize": 2048,
21 | "queuelimit": 4,
22 | "silence_threshold": -60,
23 | "silence_timeout": 3.0,
24 | "target_level": 500,
25 | "adjust_period": 10,
26 | "enable_rate_adjust": True,
27 | "resampler_type": "BalancedAsync",
28 | "enable_resampling": False,
29 | "capture_samplerate": 44100,
30 | "stop_on_rate_change": False,
31 | "rate_measure_interval": 1.0,
32 | "capture": {"type": "Stdin", "channels": 2, "format": "S16LE"},
33 | "playback": {"type": "Stdout", "channels": 2, "format": "S32LE"},
34 | },
35 | "filters": {
36 | "vol": {"type": "Volume", "parameters": {"ramp_time": 200}},
37 | "hp_80": {
38 | "type": "Biquad",
39 | "parameters": {"type": "Highpass", "freq": 80, "q": 0.5},
40 | },
41 | "loudness": {
42 | "type": "Loudness",
43 | "parameters": {
44 | "ramp_time": 200.0,
45 | "reference_level": -25.0,
46 | "high_boost": 7.0,
47 | "low_boost": 7.0,
48 | },
49 | },
50 | "dither": {"type": "Dither", "parameters": {"type": "Simple", "bits": 16}},
51 | },
52 | "mixers": {},
53 | "pipeline": [
54 | {"type": "Filter", "channel": 0, "names": ["vol", "hp_80"]},
55 | {"type": "Filter", "channel": 1, "names": ["vol"]},
56 | ],
57 | }
58 | yield config
59 |
60 |
61 | def test_coreaudio_device(basic_config):
62 | config = basic_config
63 | # Insert CoreAudio capture and playback devices
64 | config["devices"]["capture"] = {
65 | "type": "CoreAudio",
66 | "channels": 2,
67 | "device": "Soundflower (2ch)",
68 | "format": "S32LE",
69 | "change_format": True,
70 | }
71 | config["devices"]["playback"] = {
72 | "type": "CoreAudio",
73 | "channels": 2,
74 | "device": "Built-in Output",
75 | "format": "S32LE",
76 | "exclusive": False,
77 | "change_format": False,
78 | }
79 | _modify_devices(config)
80 | capture = config["devices"]["capture"]
81 | playback = config["devices"]["playback"]
82 | assert "change_format" not in capture
83 | assert "change_format" not in playback
84 | assert capture["format"] == "S32LE"
85 | assert playback["format"] == None
86 |
87 |
88 | def test_disabled_resampling(basic_config):
89 | _modify_devices(basic_config)
90 | assert "enable_resampling" not in basic_config["devices"]
91 | assert basic_config["devices"]["resampler"] == None
92 |
93 |
94 | def test_pipeline_filter_step_channels(basic_config):
95 | _modify_pipeline_filter_steps(basic_config)
96 | for step in basic_config["pipeline"]:
97 | assert "channel" not in step
98 | assert isinstance(step["channels"], list)
99 |
100 |
101 | def test_removed_volume_filters(basic_config):
102 | _remove_volume_filters(basic_config)
103 | assert "vol" not in basic_config["filters"]
104 | assert len(basic_config["pipeline"]) == 1
105 | assert basic_config["pipeline"][0]["names"] == ["hp_80"]
106 |
107 |
108 | def test_update_loudness_filters(basic_config):
109 | _modify_loundness_filters(basic_config)
110 | params = basic_config["filters"]["loudness"]["parameters"]
111 | assert "ramp_time" not in params
112 | assert params["fader"] == "Main"
113 | assert params["attenuate_mid"] == False
114 |
115 |
116 | def test_modify_dither(basic_config):
117 | _modify_dither(basic_config)
118 | params = basic_config["filters"]["dither"]["parameters"]
119 | assert params["type"] == "Highpass"
120 |
121 |
122 | def test_free_resampler(basic_config):
123 | basic_config["devices"]["resampler_type"] = {
124 | "FreeAsync": {
125 | "f_cutoff": 0.9,
126 | "sinc_len": 128,
127 | "window": "Hann2",
128 | "oversampling_ratio": 64,
129 | "interpolation": "Cubic",
130 | }
131 | }
132 | basic_config["devices"]["enable_resampling"] = True
133 | _modify_devices(basic_config)
134 | assert "enable_resampling" not in basic_config["devices"]
135 | assert basic_config["devices"]["resampler"] == {
136 | "type": "AsyncSinc",
137 | "f_cutoff": 0.9,
138 | "sinc_len": 128,
139 | "window": "Hann2",
140 | "oversampling_factor": 64,
141 | "interpolation": "Cubic",
142 | }
143 |
144 |
145 | def test_schema_validation(basic_config):
146 | # verify that the test config is not yet valid
147 | validator = CamillaValidator()
148 | validator.validate_config(basic_config)
149 | errors = validator.get_errors()
150 | assert len(errors) > 0
151 |
152 | # migrate and validate
153 | migrate_legacy_config(basic_config)
154 | validator.validate_config(basic_config)
155 | errors = validator.get_errors()
156 | assert len(errors) == 0
157 |
158 |
159 | def test_filters_only(basic_config):
160 | # make a config containing only filters,
161 | # to check that partial configs can be translated
162 | filters_only = {"filters": basic_config["filters"]}
163 | migrate_legacy_config(filters_only)
164 | assert len(filters_only["filters"]) == 3
165 |
166 |
167 | def test_rew_export(basic_config):
168 | # REW exports a single pipeline step rather than a list.
169 | # Check that this is handled ok.
170 | basic_config["pipeline"] = basic_config["pipeline"][0]
171 | migrate_legacy_config(basic_config)
172 | assert len(basic_config["pipeline"]) == 1
173 |
--------------------------------------------------------------------------------
/tests/testfiles/config.yml:
--------------------------------------------------------------------------------
1 | ---
2 | devices:
3 | samplerate: 44100
4 | chunksize: 1024
5 | capture:
6 | type: Stdin
7 | channels: 2
8 | format: S16LE
9 | playback:
10 | type: Stdout
11 | channels: 2
12 | format: S16LE
--------------------------------------------------------------------------------
/tests/testfiles/config2.yml:
--------------------------------------------------------------------------------
1 | ---
2 | devices:
3 | samplerate: 48000
4 | chunksize: 1024
5 | capture:
6 | type: Stdin
7 | channels: 2
8 | format: S16LE
9 | playback:
10 | type: Stdout
11 | channels: 2
12 | format: S16LE
--------------------------------------------------------------------------------
/tests/testfiles/gui_config.yml:
--------------------------------------------------------------------------------
1 | ---
2 | hide_capture_samplerate: false
3 | hide_silence: false
4 | hide_capture_device: false
5 | hide_playback_device: false
6 | hide_rate_monitoring: false
7 | hide_multithreading: false
8 | apply_config_automatically: false
9 | status_update_interval: 100
10 | volume_range: 50
11 | volume_max: 0
--------------------------------------------------------------------------------
/tests/testfiles/log.txt:
--------------------------------------------------------------------------------
1 | Log message 1
2 | Log message 2
--------------------------------------------------------------------------------
/tests/testfiles/statefile_template.yml:
--------------------------------------------------------------------------------
1 | ---
2 | config_path: config2.yml
3 | mute:
4 | - false
5 | - false
6 | - false
7 | - false
8 | - false
9 | volume:
10 | - 0.0
11 | - 0.0
12 | - 0.0
13 | - 0.0
14 | - 0.0
--------------------------------------------------------------------------------