├── .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 --------------------------------------------------------------------------------