├── .envrc ├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── app-icon.png ├── assets └── demo.mp4 ├── build.sh ├── flake.lock ├── flake.nix ├── get_models.sh ├── index.html ├── nix └── torch-bin.nix ├── package-lock.json ├── package.json ├── public ├── tauri.svg └── vite.svg ├── src-tauri ├── .cargo │ └── config.toml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── resources │ ├── blue_stem_icon.png │ ├── green_stem_icon.png │ ├── purple_stem_icon.png │ ├── red_stem_icon.png │ └── yellow_stem_icon.png ├── src │ ├── data │ │ ├── fsio.rs │ │ └── mod.rs │ ├── demucs │ │ ├── audio.rs │ │ ├── error.rs │ │ ├── mod.rs │ │ └── model.rs │ ├── lib.rs │ ├── main.rs │ ├── routes │ │ ├── mod.rs │ │ ├── project.rs │ │ └── split.rs │ └── util.rs └── tauri.conf.json ├── src ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── project │ │ ├── TrackDetails.tsx │ │ └── project.tsx │ ├── search │ │ └── SongSearch.tsx │ └── sidebar │ │ └── Sidebar.tsx ├── functions │ ├── project.ts │ └── split.ts ├── main.tsx ├── store │ ├── project │ │ ├── index.ts │ │ └── types.d.ts │ ├── ui │ │ ├── index.ts │ │ └── types.d.ts │ └── user │ │ ├── index.ts │ │ └── types.d.ts ├── styles.css ├── theme.ts ├── util │ ├── misc.ts │ ├── project.ts │ └── spotify.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.envrc: -------------------------------------------------------------------------------- 1 | if ! has nix_direnv_version || ! nix_direnv_version 2.1.1; then 2 | source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.1.1/direnvrc" "sha256-b6qJ4r34rbE23yWjMqbmu3ia2z4b2wIlZUksBke/ol0=" 3 | fi 4 | 5 | use_flake 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .direnv/ 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | src-tauri/models/2stems 29 | src-tauri/models/4stems 30 | src-tauri/models/5stems 31 | result -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "semi": false, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"], 3 | "editor.semanticTokenColorCustomizations": { 4 | "enabled": true, 5 | "rules": { 6 | "*.mutable": { 7 | "underline": false, 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tirth Jain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tune Prism 2 | 3 | Split a track into 4 stems: vocals, drums, bass and others. Based on Facebook's HTDemucs model ([repo](https://www.google.com/search?q=demucs+facebook&oq=demucs+fac&sourceid=chrome&ie=UTF-8)). 4 | 5 | 6 | Built with Rust, Tauri, PyTorch and React. 7 | 8 | ## Demo 9 | Simply drag a track in, extract stems and drag your stems out. 10 | 11 | https://github.com/user-attachments/assets/584cf59e-ef4b-4f24-913d-dc52d7549609 12 | 13 | 14 | ## Try it Out 15 | For M1 macs running MacOS, there's a prebuilt binary available on the releases page. Currently, that's the only platform I have built and tested the app on. Porting to other platforms is a bit of work and I only own a MacBook. If you can make the app run on Linux or Windows machines, I will happily accept your PR. 16 | 17 | ## Building Locally 18 | 19 | These instructions have been tested to work on an M1 Macbook Pro running MacOS 20 | 21 | ### Requirements 22 | 23 | #### Rust and Cargo 24 | You can install Rust using [rustup](rustup.rs). I don't know what the MSRV is but I used `v1.79.0` while building the app. 25 | 26 | ```bash 27 | $ rustc --version 28 | rustc 1.79.0 (129f3b996 2024-06-10) 29 | 30 | $ cargo --version 31 | cargo 1.79.0 (ffa9cf99a 2024-06-03) 32 | ``` 33 | #### Node and NPM 34 | ```bash 35 | $ brew install node@20 36 | 37 | $ node --version 38 | v20.14.0 39 | 40 | $ npm --version 41 | 10.7.0 42 | ``` 43 | 44 | #### PyTorch 45 | 46 | You can either use `libtorch` or provide the path to a PYTORCH installation. I found it easier to use `libtorch` directly. 47 | 48 | ```bash 49 | $ wget https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.2.0.zip 50 | $ unzip libtorch-macos-arm64-2.2.0.zip 51 | ``` 52 | 53 | #### Misc Dependencies 54 | 55 | ```bash 56 | $ brew install libomp 57 | ``` 58 | 59 | ### Building the app 60 | 61 | - Clone the repo 62 | ```bash 63 | $ git clone https://github.com/hedonhermdev/tune-prism && cd tune-prism 64 | ``` 65 | 66 | - Install npm dependencies 67 | ```bash 68 | $ npm install 69 | ``` 70 | 71 | - Download the models 72 | You can use the ``get_models.sh`` script to download the models 73 | ```bash 74 | $ ./get_models.sh 75 | ``` 76 | 77 | - Copy `libtorch` to the repo. 78 | ``` 79 | $ cp PATH_TO_LIBTORCH ./libtorch 80 | $ export LIBTORCH=$(realpath ./libtorch) 81 | ``` 82 | 83 | After this you're all set to start building the app. 84 | 85 | ```bash 86 | $ npm run tauri build 87 | $ npm run tauri dev # for development 88 | ``` 89 | 90 | # Contributing 91 | 92 | Just open a PR :) 93 | -------------------------------------------------------------------------------- /app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/app-icon.png -------------------------------------------------------------------------------- /assets/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/assets/demo.mp4 -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | nix build .#stemsplit-dmg 6 | 7 | ./result/target/release/bundle/dmg/bundle_dmg.sh stem-split.dmg ./result/target/release/bundle/macos/stem-split.app/ 8 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1704300976, 11 | "narHash": "sha256-QLMpTrHxsND2T8+khAhLCqzOY/h2SzWS0s4Z7N2ds/E=", 12 | "owner": "ipetkov", 13 | "repo": "crane", 14 | "rev": "0efe36f9232e0961512572883ba9c995aa1f54b1", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "ipetkov", 19 | "repo": "crane", 20 | "type": "github" 21 | } 22 | }, 23 | "flake-utils": { 24 | "inputs": { 25 | "systems": "systems" 26 | }, 27 | "locked": { 28 | "lastModified": 1701680307, 29 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 30 | "owner": "numtide", 31 | "repo": "flake-utils", 32 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 33 | "type": "github" 34 | }, 35 | "original": { 36 | "owner": "numtide", 37 | "repo": "flake-utils", 38 | "type": "github" 39 | } 40 | }, 41 | "flake-utils_2": { 42 | "inputs": { 43 | "systems": "systems_2" 44 | }, 45 | "locked": { 46 | "lastModified": 1681202837, 47 | "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", 48 | "owner": "numtide", 49 | "repo": "flake-utils", 50 | "rev": "cfacdce06f30d2b68473a46042957675eebb3401", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "numtide", 55 | "repo": "flake-utils", 56 | "type": "github" 57 | } 58 | }, 59 | "nixpkgs": { 60 | "locked": { 61 | "lastModified": 1704532737, 62 | "narHash": "sha256-CV1elXkO4PfDm+8aDAB6XN8rt8znEoTT+SeEJ4PqC2Y=", 63 | "owner": "NixOS", 64 | "repo": "nixpkgs", 65 | "rev": "868769dc6c52c597dcf8275cd8eb7c019787536e", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "NixOS", 70 | "repo": "nixpkgs", 71 | "type": "github" 72 | } 73 | }, 74 | "nixpkgs_2": { 75 | "locked": { 76 | "lastModified": 1653917367, 77 | "narHash": "sha256-04MsJC0g9kE01nBuXThMppZK+yvCZECQnUaZKSU+HJo=", 78 | "owner": "NixOS", 79 | "repo": "nixpkgs", 80 | "rev": "437c8e6554911095f0557d524e9d2ffe1c26e33a", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "id": "nixpkgs", 85 | "type": "indirect" 86 | } 87 | }, 88 | "nixpkgs_3": { 89 | "locked": { 90 | "lastModified": 1681358109, 91 | "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", 92 | "owner": "NixOS", 93 | "repo": "nixpkgs", 94 | "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", 95 | "type": "github" 96 | }, 97 | "original": { 98 | "owner": "NixOS", 99 | "ref": "nixpkgs-unstable", 100 | "repo": "nixpkgs", 101 | "type": "github" 102 | } 103 | }, 104 | "npmPackageSerokell": { 105 | "inputs": { 106 | "nixpkgs": "nixpkgs_2" 107 | }, 108 | "locked": { 109 | "lastModified": 1686315622, 110 | "narHash": "sha256-ccqZqY6wUFot0ewyNKQUrMR6IEliGza+pjKoSVMXIeM=", 111 | "owner": "serokell", 112 | "repo": "nix-npm-buildpackage", 113 | "rev": "991a792bccd611842f6bc1aa99fe80380ad68d44", 114 | "type": "github" 115 | }, 116 | "original": { 117 | "owner": "serokell", 118 | "repo": "nix-npm-buildpackage", 119 | "type": "github" 120 | } 121 | }, 122 | "root": { 123 | "inputs": { 124 | "crane": "crane", 125 | "flake-utils": "flake-utils", 126 | "nixpkgs": "nixpkgs", 127 | "npmPackageSerokell": "npmPackageSerokell", 128 | "rust-overlay": "rust-overlay" 129 | } 130 | }, 131 | "rust-overlay": { 132 | "inputs": { 133 | "flake-utils": "flake-utils_2", 134 | "nixpkgs": "nixpkgs_3" 135 | }, 136 | "locked": { 137 | "lastModified": 1704507282, 138 | "narHash": "sha256-PDfS8fj40mm2QWpbd/aiocgwcI/WHzqLKERRJkoEvXU=", 139 | "owner": "oxalica", 140 | "repo": "rust-overlay", 141 | "rev": "a127cccf7943beae944953963ba118d643299c3b", 142 | "type": "github" 143 | }, 144 | "original": { 145 | "owner": "oxalica", 146 | "repo": "rust-overlay", 147 | "type": "github" 148 | } 149 | }, 150 | "systems": { 151 | "locked": { 152 | "lastModified": 1681028828, 153 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 154 | "owner": "nix-systems", 155 | "repo": "default", 156 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 157 | "type": "github" 158 | }, 159 | "original": { 160 | "owner": "nix-systems", 161 | "repo": "default", 162 | "type": "github" 163 | } 164 | }, 165 | "systems_2": { 166 | "locked": { 167 | "lastModified": 1681028828, 168 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 169 | "owner": "nix-systems", 170 | "repo": "default", 171 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 172 | "type": "github" 173 | }, 174 | "original": { 175 | "owner": "nix-systems", 176 | "repo": "default", 177 | "type": "github" 178 | } 179 | } 180 | }, 181 | "root": "root", 182 | "version": 7 183 | } 184 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "stem-split"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | rust-overlay.url = "github:oxalica/rust-overlay"; 8 | crane = { 9 | url = "github:ipetkov/crane"; 10 | inputs.nixpkgs.follows = "nixpkgs"; 11 | }; 12 | npmPackageSerokell = { 13 | url = "github:serokell/nix-npm-buildpackage"; 14 | }; 15 | }; 16 | 17 | outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, npmPackageSerokell, ... }: 18 | let 19 | supportedSystems = [ "aarch64-darwin" ]; 20 | in 21 | flake-utils.lib.eachSystem supportedSystems (system: 22 | let 23 | pkgs = import nixpkgs { 24 | inherit system; 25 | overlays = [ (import rust-overlay) ]; 26 | config = { 27 | allowUnfree = true; 28 | }; 29 | }; 30 | 31 | lib = pkgs.lib; 32 | 33 | rustNightly = pkgs.rust-bin.stable.latest.default.override { 34 | extensions = [ "rust-src" "rust-analyzer-preview" ]; 35 | targets = [ "x86_64-unknown-linux-gnu" ]; 36 | }; 37 | 38 | rustPlatform = pkgs.makeRustPlatform { 39 | inherit (rustNightly) cargo rustc; 40 | }; 41 | 42 | craneLib = (crane.mkLib pkgs).overrideToolchain rustNightly; 43 | 44 | libtorch = pkgs.callPackage (import ./nix/torch-bin.nix) { }; 45 | 46 | tauriConfFilter = path: _type: builtins.match ".*tauri\.conf\.json$" path != null; 47 | othersFilter = path: _type: builtins.match ".*(json|ico|icns|png|toml)$" path != null; 48 | srcFilter = path: type: (craneLib.filterCargoSources path type) || (tauriConfFilter path type) || (othersFilter path type); 49 | 50 | cargoVendorDir = craneLib.vendorCargoDeps { 51 | src = craneLib.cleanCargoSource (craneLib.path ./src-tauri); 52 | }; 53 | 54 | npmPackage = pkgs.callPackage npmPackageSerokell { }; 55 | 56 | stemsplit-bin = craneLib.buildPackage { 57 | src = lib.cleanSourceWith { 58 | src = craneLib.path ./src-tauri; # The original, unfiltered source 59 | filter = srcFilter; 60 | }; 61 | 62 | doInstallCargoArtifacts = true; 63 | cargoExtraArgs = "--config target.aarch64-apple-darwin.linker=\\'c++\\'"; 64 | 65 | LIBTORCH = "${libtorch}"; 66 | DYLD_LIBRARY_PATH = "${libtorch}/lib"; 67 | 68 | nativeBuildInputs = with pkgs; [ 69 | rustNightly 70 | llvmPackages.libcxxStdenv 71 | curl 72 | protobuf 73 | fftw 74 | nodejs 75 | pkg-config 76 | cmake 77 | fftw 78 | ]; 79 | 80 | buildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin 81 | (with pkgs; [ 82 | darwin.libobjc 83 | ]) ++ (with pkgs.darwin.apple_sdk.frameworks; [ 84 | Carbon 85 | AudioUnit 86 | WebKit 87 | ]); 88 | }; 89 | 90 | stemsplit-models = pkgs.stdenv.mkDerivation { 91 | name = "stemsplit-models"; 92 | src = pkgs.fetchzip { 93 | url = "https://pub-20137d58397b4f8f8c86ff1a178685ff.r2.dev/models.zip"; 94 | hash = "sha256-xxOUkxHCNLjFKvsp6KphQ6AC1fghk4/AdkwRuxPJGrs="; 95 | stripRoot = false; 96 | }; 97 | 98 | installPhase = '' 99 | cp -r $src $out 100 | ''; 101 | 102 | }; 103 | 104 | 105 | stemsplit-dmg = npmPackage.buildNpmPackage { 106 | src = lib.cleanSource ./.; 107 | 108 | nativeBuildInputs = with pkgs; [ 109 | rustNightly 110 | llvmPackages.libcxxStdenv 111 | curl 112 | protobuf 113 | pkg-config 114 | cmake 115 | fftw 116 | ]; 117 | 118 | buildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin 119 | (with pkgs; [ 120 | darwin.libobjc 121 | ]) ++ (with pkgs.darwin.apple_sdk.frameworks; [ 122 | Carbon 123 | AudioUnit 124 | WebKit 125 | ]); 126 | 127 | postConfigure = '' 128 | ${pkgs.zstd}/bin/zstd -d ${stemsplit-bin}/target.tar.zst --stdout | tar -xvf - -C ./src-tauri/ 129 | directory_line=$(grep "directory" ${cargoVendorDir}/config.toml) 130 | vendor_dir=$(echo "$directory_line" | awk -F'=' '{print $2}' | tr -d '[:space:]' | tr -d '"') 131 | echo $vendor_dir 132 | 133 | cat <> src-tauri/.cargo/config.toml 134 | [source.crates-io] 135 | replace-with = "vendored-sources" 136 | 137 | [source.vendored-sources] 138 | directory = "''$vendor_dir" 139 | DONE 140 | rm -rf src-tauri/models/ 141 | cp -r ${stemsplit-models}/models src-tauri/models 142 | # ''; 143 | 144 | npmBuild = '' 145 | export LIBTORCH="${libtorch}"; 146 | export DYLD_LIBRARY_PATH="${libtorch}/lib:$"; 147 | export STEMSPLIT_RESOURCES_PATH='../Resources/' 148 | npm run tauri build -- --ci -- --offline || true 149 | ''; 150 | 151 | installPhase = '' 152 | mkdir -p $out/target 153 | cp -r ./src-tauri/target/ $out/. 154 | ''; 155 | }; 156 | 157 | in 158 | { 159 | devShell = pkgs.mkShell { 160 | LIBTORCH = "${libtorch}"; 161 | 162 | nativeBuildInputs = with pkgs; [ 163 | rustNightly 164 | llvmPackages.libcxxStdenv 165 | llvmPackages.openmp 166 | libtorch 167 | curl 168 | protobuf 169 | pkg-config 170 | cmake 171 | fftw 172 | ]; 173 | 174 | buildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin 175 | (with pkgs; [ 176 | darwin.libobjc 177 | ]) ++ (with pkgs.darwin.apple_sdk.frameworks; [ 178 | Carbon 179 | AudioUnit 180 | WebKit 181 | ]); 182 | }; 183 | 184 | defaultPackage = stemsplit-bin; 185 | 186 | packages = { 187 | inherit stemsplit-bin stemsplit-dmg stemsplit-models; 188 | }; 189 | 190 | libtorch = libtorch; 191 | }); 192 | } 193 | -------------------------------------------------------------------------------- /get_models.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | wget https://pub-20137d58397b4f8f8c86ff1a178685ff.r2.dev/models.zip 5 | 6 | rm -rf src-tauri/models/ 7 | 8 | unzip models.zip -d src-tauri/ 9 | 10 | rm -rf models.zip 11 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + TS 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /nix/torch-bin.nix: -------------------------------------------------------------------------------- 1 | {stdenv, fetchzip}: 2 | let 3 | version = "2.2.0"; 4 | in stdenv.mkDerivation { 5 | inherit version; 6 | name = "libtorch-bin"; 7 | pname = "libtorch-bin"; 8 | 9 | src = fetchzip { 10 | url = "https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.2.0.zip"; 11 | hash = "sha256-09pRH2CSLgKAxB1CnqYEhRrSHLPFRBjTmiuXnhDN+a8="; 12 | }; 13 | 14 | installPhase = '' 15 | cp -r $src $out 16 | ''; 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stem-split", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@chakra-ui/react": "^2.8.2", 14 | "@crabnebula/tauri-plugin-drag": "^0.3.0", 15 | "@emotion/react": "^11.11.3", 16 | "@emotion/styled": "^11.11.0", 17 | "@tauri-apps/api": "^1.5.2", 18 | "@wavesurfer/react": "^1.0.4", 19 | "axios": "^1.6.7", 20 | "framer-motion": "^11.0.3", 21 | "lodash": "^4.17.21", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-dropzone": "^14.2.3", 25 | "react-icons": "^5.0.1", 26 | "react-router": "^6.20.1", 27 | "react-router-dom": "^6.20.1", 28 | "zustand": "^4.5.0" 29 | }, 30 | "devDependencies": { 31 | "@tauri-apps/cli": "^1.5.8", 32 | "@types/react": "^18.2.15", 33 | "@types/react-dom": "^18.2.7", 34 | "@vitejs/plugin-react": "^4.2.1", 35 | "typescript": "^5.0.2", 36 | "vite": "^5.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-apple-darwin] 2 | linker = "c++" 3 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | libtorch 5 | models/* -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stem_split" 3 | version = "0.0.0" 4 | description = "Stem splitter" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "1.5", features = [] } 14 | 15 | [dependencies] 16 | dasp = { version = "0.11.0", features = ["all"] } 17 | fraction = "0.15.1" 18 | hound = "3.5.1" 19 | itertools = "0.12.1" 20 | lazy_static = "1.4.0" 21 | polodb_core = "4.4.0" 22 | rand = "0.8.5" 23 | serde = { version = "1.0", features = ["derive"] } 24 | serde_json = "1.0" 25 | snafu = "0.8.0" 26 | reqwest = { version = "0.11.24", features = ["json"] } 27 | axum = { version = "0.7.4", features = ["macros"] } 28 | oauth2 = "4.4.2" 29 | tokio = "1.36.0" 30 | open = "5.0.1" 31 | kv = "0.24.0" 32 | symphonia = { version = "0.5.3", features = ["all"] } 33 | tauri = { version = "1.5", features = [ "path-all", "fs-all", "protocol-all", "shell-open"] } 34 | tch = "0.15" 35 | once_cell = "1.19.0" 36 | ndarray = "0.15.6" 37 | rayon = "1.8.1" 38 | id3 = "1.12.0" 39 | mime = "0.3.17" 40 | tracing = "0.1.40" 41 | tracing-subscriber = "0.3.18" 42 | tauri-plugin-drag = "0.3.0" 43 | 44 | [dev-dependencies] 45 | criterion = "0.3" 46 | 47 | [profile.release] 48 | strip = "symbols" 49 | 50 | [features] 51 | # this feature is used for production builds or when `devPath` points to the filesystem 52 | # DO NOT REMOVE!! 53 | custom-protocol = ["tauri/custom-protocol"] 54 | 55 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("cargo:rustc-link-arg=-std=c++17"); 3 | tauri_build::build() 4 | } 5 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/resources/blue_stem_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/resources/blue_stem_icon.png -------------------------------------------------------------------------------- /src-tauri/resources/green_stem_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/resources/green_stem_icon.png -------------------------------------------------------------------------------- /src-tauri/resources/purple_stem_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/resources/purple_stem_icon.png -------------------------------------------------------------------------------- /src-tauri/resources/red_stem_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/resources/red_stem_icon.png -------------------------------------------------------------------------------- /src-tauri/resources/yellow_stem_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src-tauri/resources/yellow_stem_icon.png -------------------------------------------------------------------------------- /src-tauri/src/data/fsio.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use crate::{demucs, util::get_base_directory}; 4 | 5 | pub fn copy_song_to_project(song_path: PathBuf, project_id: String) -> Result<(), String> { 6 | let base_dir_path = get_base_directory(); 7 | let project_dir_path = base_dir_path.join("project_data").join(project_id.clone()); 8 | fs::create_dir_all(&project_dir_path).expect("Unable to ensure parent directory exists"); 9 | let dest_path = song_path 10 | .extension() 11 | .map(|extension| { 12 | base_dir_path 13 | .join("project_data") 14 | .join(project_id) 15 | .join(format!("main.{}", extension.to_string_lossy())) 16 | }) 17 | .ok_or(String::from("That's not a file, dumbass."))?; 18 | 19 | // println!("{}, {}", song_path.to_string_lossy().to_string(), dest_path.to_string_lossy().to_string()); 20 | fs::copy(song_path, &dest_path).map_err(|_| String::from("Error copying song"))?; 21 | 22 | let _cover_image = demucs::get_cover_image(&dest_path, &project_dir_path) 23 | .map_err(|e| format!("failed to fetch cover image: {e}"))?; 24 | 25 | Ok(()) 26 | } 27 | 28 | pub fn delete_project_data(project_id: String) -> Result<(), String> { 29 | let base_dir_path = get_base_directory(); 30 | let proj_dir_path = base_dir_path.join("projects").join(project_id); 31 | 32 | match fs::remove_dir_all(proj_dir_path) { 33 | Ok(_) => Ok(()), 34 | Err(_) => Err(String::from("Error deleting project.")), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src-tauri/src/data/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{current_unix_timestamp, generate_random_string, get_base_directory}; 2 | use polodb_core::{bson::doc, Collection, Database}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::path::PathBuf; 5 | 6 | use self::fsio::{copy_song_to_project, delete_project_data}; 7 | 8 | mod fsio; 9 | 10 | #[derive(Serialize, Deserialize)] 11 | #[serde(tag = "type")] 12 | pub enum AppMetadata { 13 | #[serde(alias = "activation")] 14 | Activation { key: String }, 15 | 16 | #[serde(alias = "num_songs_processed")] 17 | Error { value: u32 }, 18 | } 19 | 20 | #[derive(Debug, Serialize, Deserialize, Clone)] 21 | pub struct Project { 22 | pub _id: String, 23 | pub name: String, 24 | pub created_at: i64, 25 | pub base_dir: PathBuf, 26 | pub stem_paths: Vec, 27 | } 28 | 29 | pub struct AppDb { 30 | pub path: PathBuf, 31 | polo_instance: Database, 32 | } 33 | 34 | // TODO: Implement non-monkey error handling 35 | impl AppDb { 36 | pub fn new(path: PathBuf) -> Self { 37 | let db = Database::open_file(path.clone()).unwrap(); 38 | Self { 39 | path: path.clone(), 40 | polo_instance: db, 41 | } 42 | } 43 | 44 | pub fn create_project(&self, audio_filepath: PathBuf) -> Result { 45 | let name = audio_filepath 46 | .file_name() 47 | .ok_or_else(String::new)? 48 | .to_string_lossy() 49 | .to_string(); 50 | 51 | let created_at = current_unix_timestamp(); 52 | let projects = self.polo_instance.collection("projects"); 53 | let base_dir = get_base_directory(); 54 | let id = generate_random_string(); 55 | let stem_paths: Vec = vec![]; 56 | 57 | let proj = Project { 58 | _id: id.clone(), // Not sure if polo_db will work if this is an Option 59 | name, 60 | created_at, 61 | base_dir, 62 | stem_paths, 63 | }; 64 | 65 | projects 66 | .insert_one(proj.clone()) 67 | .map_err(|_| String::new())?; 68 | copy_song_to_project(audio_filepath, id.clone()).expect("Failed to copy song"); 69 | 70 | Ok(proj) 71 | } 72 | 73 | pub fn add_stems_to_project( 74 | &self, 75 | project_id: String, 76 | stem_paths: Vec, 77 | ) -> Result<(), String> { 78 | let paths: Vec = stem_paths 79 | .into_iter() 80 | .map(|p| p.to_string_lossy().to_string()) 81 | .collect(); 82 | let projects: Collection = self.polo_instance.collection("projects"); 83 | let result = projects.update_one( 84 | doc! { "_id": project_id.clone() }, 85 | doc! { 86 | "$set": doc! { 87 | // "stem_paths": paths.into_iter().map(Bson::String).collect(), 88 | "stem_paths": paths.clone(), 89 | } 90 | }, 91 | ); 92 | 93 | result.map_err(|_| String::new())?; 94 | 95 | Ok(()) 96 | } 97 | 98 | pub fn get_projects(&self) -> Result, String> { 99 | let projects_collection: Collection = self.polo_instance.collection("projects"); 100 | let result = projects_collection.find(None); 101 | match result { 102 | Ok(res) => { 103 | let mut all_projects: Vec = vec![]; 104 | for proj_res in res { 105 | let project = proj_res.expect("Couldn't read the project."); 106 | all_projects.push(project); 107 | } 108 | Ok(dbg!(all_projects)) 109 | } 110 | Err(_) => Err(String::from("bruh")), 111 | } 112 | } 113 | 114 | pub fn get_project_by_id(&self, id: String) -> Result, String> { 115 | let projects_collection: Collection = self.polo_instance.collection("projects"); 116 | let find_result = projects_collection.find_one(doc! { 117 | "_id": id 118 | }); 119 | 120 | match find_result { 121 | Ok(result) => Ok(result), 122 | Err(_e) => Err(String::from("Error finding project by ID")), 123 | } 124 | } 125 | 126 | pub fn delete_project_by_id(&self, project_id: String) -> Result<(), String> { 127 | let projects_collection: Collection = self.polo_instance.collection("projects"); 128 | let deleted_result = projects_collection.delete_many(doc! { 129 | "_id": project_id.clone(), 130 | }); 131 | 132 | match deleted_result { 133 | Ok(_) => { 134 | delete_project_data(project_id.clone()).expect("Failed to delete project data."); 135 | Ok(()) 136 | } 137 | Err(_) => Err(String::from("Error deleting, whoops.")), 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src-tauri/src/demucs/audio.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, path::Path}; 2 | 3 | use dasp::Signal as _; 4 | 5 | use snafu::{whatever, ResultExt}; 6 | use symphonia::core::audio::Signal as _; 7 | use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; 8 | use symphonia::core::formats::FormatOptions; 9 | use symphonia::core::meta::MetadataOptions; 10 | use symphonia::core::{errors::Error, io::MediaSourceStream, probe::Hint}; 11 | 12 | use super::Result; 13 | 14 | use super::error::HoundSnafu; 15 | 16 | #[derive(Clone)] 17 | pub struct PcmAudioData { 18 | pub samples: Vec>, 19 | pub sample_rate: usize, 20 | pub nb_channels: usize, 21 | pub length: usize, 22 | } 23 | 24 | impl PcmAudioData { 25 | pub fn as_interleaved(&self) -> Vec { 26 | let mut buffer = Vec::with_capacity(self.length * self.nb_channels); 27 | 28 | for i in 0..self.length { 29 | for channel in self.samples.iter() { 30 | buffer.push(channel[i]); 31 | } 32 | } 33 | 34 | buffer 35 | } 36 | } 37 | 38 | impl std::fmt::Debug for PcmAudioData { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | f.debug_struct("PcmAudioData") 41 | .field("samples", &self.samples.len()) 42 | .field("sample_rate", &self.sample_rate) 43 | .field("nb_channels", &self.nb_channels) 44 | .finish() 45 | } 46 | } 47 | 48 | pub fn decode_file(path: &Path) -> Result { 49 | let ext = path.extension(); 50 | 51 | let src = File::open(path).unwrap(); 52 | 53 | let mss = MediaSourceStream::new(Box::new(src), Default::default()); 54 | let mut hint = Hint::new(); 55 | 56 | if let Some(ext) = ext { 57 | hint.with_extension(&ext.to_string_lossy()); 58 | } 59 | 60 | let meta_opts: MetadataOptions = Default::default(); 61 | let mut fmt_opts: FormatOptions = Default::default(); 62 | fmt_opts.enable_gapless = true; 63 | let probed = symphonia::default::get_probe() 64 | .format(&hint, mss, &fmt_opts, &meta_opts) 65 | .expect("unsupported format"); 66 | 67 | let mut format = probed.format; 68 | 69 | // Find the first audio track with a known (decodeable) codec. 70 | let track = format 71 | .tracks() 72 | .iter() 73 | .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) 74 | .expect("no supported audio tracks"); 75 | 76 | let track_id = track.id; 77 | 78 | let dec_opts: DecoderOptions = Default::default(); 79 | 80 | dbg!(track); 81 | 82 | let mut decoder = symphonia::default::get_codecs() 83 | .make(&track.codec_params, &dec_opts) 84 | .expect("unsupported codec"); 85 | 86 | let nb_channels = track.codec_params.channels.unwrap().count(); 87 | let sample_rate = track.codec_params.sample_rate.unwrap() as usize; 88 | 89 | let mut buffer: Vec> = (0..nb_channels).map(|_| Vec::new()).collect(); 90 | 'decode: loop { 91 | // Get the next packet from the media format. 92 | let packet = match format.next_packet() { 93 | Ok(packet) => packet, 94 | Err(Error::ResetRequired) => { 95 | unimplemented!(); 96 | } 97 | Err(err) => { 98 | if let Error::IoError(_) = err { 99 | break 'decode; 100 | } else { 101 | return Err(super::Error::SymphoniaError { source: err }); 102 | } 103 | } 104 | }; 105 | 106 | while !format.metadata().is_latest() { 107 | format.metadata().pop(); 108 | 109 | dbg!(format.metadata()); 110 | } 111 | 112 | if packet.track_id() != track_id { 113 | continue; 114 | } 115 | 116 | match decoder.decode(&packet) { 117 | Ok(decoded) => match decoded { 118 | symphonia::core::audio::AudioBufferRef::F32(buf) => { 119 | for ch in 0..nb_channels { 120 | buffer[ch].extend_from_slice(buf.chan(ch)); 121 | } 122 | } 123 | _ => { 124 | unimplemented!() 125 | } 126 | }, 127 | Err(Error::IoError(_)) => { 128 | continue; 129 | } 130 | Err(Error::DecodeError(_)) => { 131 | continue; 132 | } 133 | Err(err) => { 134 | return Err(super::Error::SymphoniaError { source: err }); 135 | } 136 | } 137 | } 138 | 139 | let samples = buffer; 140 | 141 | let length = samples[0].len(); 142 | 143 | Ok(PcmAudioData { 144 | samples, 145 | sample_rate, 146 | nb_channels, 147 | length, 148 | }) 149 | } 150 | 151 | pub fn encode_pcm_to_wav(audio: PcmAudioData, path: &Path) -> Result<()> { 152 | let wav_spec = hound::WavSpec { 153 | channels: audio.nb_channels as u16, 154 | sample_rate: audio.sample_rate as u32, 155 | bits_per_sample: 32, 156 | sample_format: hound::SampleFormat::Float, 157 | }; 158 | 159 | let mut writer = hound::WavWriter::create(path, wav_spec).unwrap(); 160 | 161 | for i in 0..audio.length { 162 | for channel in audio.samples.iter() { 163 | writer.write_sample(channel[i]).context(HoundSnafu)?; 164 | } 165 | } 166 | 167 | writer.finalize().context(HoundSnafu)?; 168 | 169 | Ok(()) 170 | } 171 | 172 | pub fn resample(input: PcmAudioData, to_sample_rate: usize) -> Result { 173 | if input.nb_channels != 2 { 174 | whatever!("resampling is currently implemented for stereo audio only.") 175 | } 176 | 177 | let samples = input.as_interleaved(); 178 | let mut signal = dasp::signal::from_interleaved_samples_iter::<_, [f32; 2]>(samples); 179 | 180 | let linear = dasp::interpolate::linear::Linear::new(signal.next(), signal.next()); 181 | let new_signal = signal.from_hz_to_hz(linear, input.sample_rate as f64, to_sample_rate as f64); 182 | 183 | let mut pcm_buffer = vec![vec![]; input.nb_channels]; 184 | 185 | for frame in new_signal.until_exhausted() { 186 | pcm_buffer[0].push(frame[0]); 187 | pcm_buffer[1].push(frame[1]); 188 | } 189 | let length = pcm_buffer[0].len(); 190 | 191 | Ok(PcmAudioData { 192 | samples: pcm_buffer, 193 | sample_rate: to_sample_rate, 194 | nb_channels: 2, 195 | length, 196 | }) 197 | } 198 | -------------------------------------------------------------------------------- /src-tauri/src/demucs/error.rs: -------------------------------------------------------------------------------- 1 | use snafu::prelude::*; 2 | 3 | #[derive(Debug, Snafu)] 4 | #[snafu(visibility(pub))] 5 | pub enum Error { 6 | #[snafu(whatever, display("Unexpected Error: {message}: {source:?}"))] 7 | UnexpectedError { 8 | message: String, 9 | #[snafu(source(from(Box, Some)))] 10 | source: Option>, 11 | }, 12 | 13 | #[snafu(display("Model not found. Check if it is available in the right place."))] 14 | ModelNotFoundError { name: String }, 15 | 16 | #[snafu(display("Symphonia Error: {source:?}"))] 17 | SymphoniaError { 18 | source: symphonia::core::errors::Error, 19 | }, 20 | 21 | #[snafu(display("Hound Error: {source:?}"))] 22 | HoundError { source: hound::Error }, 23 | 24 | #[snafu(display("Torch Error: {source:?}"))] 25 | TorchError { source: tch::TchError }, 26 | 27 | #[snafu(display("ID3 Error: {source:?}"))] 28 | Id3Error { source: id3::Error }, 29 | 30 | #[snafu(display("Mime parse error: {source:?}"))] 31 | MimeParseError { source: mime::FromStrError }, 32 | } 33 | 34 | pub type Result = std::result::Result; 35 | -------------------------------------------------------------------------------- /src-tauri/src/demucs/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | io::Write, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | pub mod audio; 8 | pub mod error; 9 | pub mod model; 10 | 11 | use mime::{Mime, IMAGE, JPEG}; 12 | use ndarray::{Array2, ArrayD}; 13 | 14 | use rayon::prelude::*; 15 | use snafu::{whatever, ResultExt}; 16 | use tch::{Device, IndexOp, Kind, Tensor}; 17 | 18 | use crate::demucs::{ 19 | audio::{decode_file, encode_pcm_to_wav, resample, PcmAudioData}, 20 | error::TorchSnafu, 21 | }; 22 | 23 | pub use error::{Error, Result}; 24 | pub use model::{find_model, models, Demucs}; 25 | 26 | use self::error::{Id3Snafu, MimeParseSnafu}; 27 | 28 | pub fn get_available_device() -> Device { 29 | if tch::utils::has_mps() { 30 | Device::Mps 31 | } else if tch::utils::has_cuda() { 32 | Device::Cuda(0) 33 | } else { 34 | Device::Cpu 35 | } 36 | } 37 | 38 | pub fn split_track(model: &Demucs, input_path: &Path, output_dir: &Path) -> Result> { 39 | // let model = &MODEL; 40 | let track = decode_file(input_path)?; 41 | let track = resample(track, model.config.sample_rate)?; 42 | 43 | let input_arr: ArrayD = Array2::from_shape_vec( 44 | (track.nb_channels, track.length), 45 | track.samples.into_iter().flatten().collect(), 46 | ) 47 | .unwrap() 48 | .into_dyn(); 49 | 50 | let mut input_tensor: Tensor = (&input_arr).try_into().context(TorchSnafu)?; 51 | 52 | let r = input_tensor.mean_dim(0, false, Kind::Float); 53 | 54 | input_tensor -= r.mean(Kind::Float); 55 | input_tensor /= r.std(true); 56 | 57 | let length = input_tensor.size().pop().unwrap(); 58 | let input = input_tensor.reshape([1, 2, length]); 59 | 60 | let mut output = model.apply(input); 61 | 62 | output *= r.std(true); 63 | output += r.mean(None); 64 | 65 | // let output = Arc::new(output); 66 | 67 | model 68 | .config 69 | .sources 70 | .iter() 71 | .enumerate() 72 | .map(|(i, source)| { 73 | let mut buffer: Vec> = vec![vec![0.0; track.length]; model.config.channels]; 74 | 75 | let out = output.i((0, i as i64)); 76 | 77 | for i in 0..model.config.channels { 78 | out.i(i as i64).copy_data(&mut buffer[i], track.length); 79 | } 80 | (source, buffer) 81 | }) 82 | .collect::>() 83 | .into_par_iter() 84 | .map(|(source, buffer)| { 85 | let audio_data = PcmAudioData { 86 | samples: buffer, 87 | sample_rate: model.config.sample_rate, 88 | nb_channels: model.config.channels, 89 | length: track.length, 90 | }; 91 | 92 | let mut stem = source.clone(); 93 | stem.push_str(".wav"); 94 | let path = output_dir.join(stem); 95 | 96 | encode_pcm_to_wav(audio_data, &path)?; 97 | 98 | Ok(path) 99 | }) 100 | .collect::>>() 101 | } 102 | 103 | pub fn get_cover_image(path: &Path, output_dir: &Path) -> Result> { 104 | let tags = id3::Tag::read_from_path(path).context(Id3Snafu)?; 105 | 106 | let output = if let Some(image) = tags.pictures().next() { 107 | let mime: Mime = image.mime_type.parse().context(MimeParseSnafu)?; 108 | if mime.type_() == IMAGE && mime.subtype() == JPEG { 109 | let path = output_dir.join("cover.jpg"); 110 | let mut output = whatever!( 111 | File::options() 112 | .create(true) 113 | .write(true) 114 | .truncate(true) 115 | .open(&path), 116 | "failed to open file" 117 | ); 118 | 119 | whatever!(output.write_all(&image.data), "failed to write to file"); 120 | Ok(Some(path)) 121 | } else { 122 | Ok(None) 123 | } 124 | } else { 125 | Ok(None) 126 | }; 127 | 128 | dbg!(&output); 129 | 130 | output 131 | } 132 | -------------------------------------------------------------------------------- /src-tauri/src/demucs/model.rs: -------------------------------------------------------------------------------- 1 | use fraction::{Fraction, ToPrimitive}; 2 | 3 | use rand::Rng; 4 | use serde::Deserialize; 5 | use snafu::{whatever, ResultExt}; 6 | use tch::{nn::ModuleT, CModule, Device, IndexOp, Tensor}; 7 | 8 | use std::{ 9 | cmp::{max, min}, 10 | fs::File, 11 | ops::AddAssign, 12 | path::Path, 13 | }; 14 | 15 | use super::error::{Result, TorchSnafu}; 16 | 17 | #[derive(Debug, Clone, Deserialize)] 18 | pub struct ModelConfig { 19 | pub sample_rate: usize, 20 | pub sources: Vec, 21 | pub channels: usize, 22 | } 23 | 24 | #[derive(Debug, Clone, Deserialize)] 25 | pub struct ModelInfo { 26 | pub(crate) name: String, 27 | pub(crate) config: ModelConfig, 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct Demucs { 32 | pub module: CModule, 33 | pub config: ModelConfig, 34 | pub device: Device, 35 | } 36 | 37 | pub fn models(path: &Path) -> Result> { 38 | let models_json = whatever!(File::open(path), "failed to read models.json"); 39 | 40 | let models: Vec = whatever!( 41 | serde_json::from_reader(models_json), 42 | "failed to read models.json" 43 | ); 44 | 45 | Ok(models) 46 | } 47 | 48 | pub fn find_model(models: Vec, name: &str) -> Option { 49 | models.iter().find(|m| m.name == name).cloned() 50 | } 51 | 52 | impl Demucs { 53 | pub fn init(path: &Path, info: &ModelInfo, device: Device) -> Result { 54 | let config = info.config.clone(); 55 | 56 | let mut module = CModule::load(path).context(TorchSnafu)?; 57 | 58 | module.to(device, tch::Kind::Float, false); 59 | 60 | Ok(Self { 61 | config, 62 | module, 63 | device, 64 | }) 65 | } 66 | 67 | pub fn apply(&self, input: Tensor) -> Tensor { 68 | assert_eq!( 69 | input.dim(), 70 | 3, 71 | "expected input to be a 3 dimensional tensor" 72 | ); 73 | 74 | let input = input.to(self.device); 75 | 76 | self._apply( 77 | TensorChunk::new(&input, 0, None), 78 | ApplyArgs { 79 | shifts: 1, 80 | split: true, 81 | overlap: 0.25, 82 | transition_power: 1.0, 83 | device: self.device, 84 | segment: Fraction::new(39u64, 5u64), 85 | }, 86 | ) 87 | } 88 | 89 | fn _apply(&self, input: TensorChunk, mut args: ApplyArgs) -> Tensor { 90 | let shape = input.size(); 91 | let batch = shape[0]; 92 | let channels = shape[1]; 93 | let length = shape[2]; 94 | let kind = input.tensor.kind(); 95 | let device = input.tensor.device(); 96 | 97 | assert_eq!(channels, self.config.channels as i64); 98 | 99 | if args.shifts > 0 { 100 | let _out = Tensor::zeros(input.size(), (kind, device)); 101 | let shifts = args.shifts; 102 | 103 | args.shifts = 0; 104 | 105 | let max_shift = (self.config.sample_rate / 2) as i64; 106 | 107 | let padded = input.padded(length + 2 * max_shift); 108 | 109 | let mut out = Tensor::zeros( 110 | [batch, self.config.sources.len() as i64, channels, length], 111 | (kind, device), 112 | ); 113 | for _ in 0..shifts { 114 | let offset = rand::thread_rng().gen_range(0..max_shift); 115 | let shifted = TensorChunk::new(&padded, offset, Some(length + max_shift - offset)); 116 | let shifted_out = self._apply(shifted, args.clone()); 117 | 118 | out += shifted_out.i((.., .., .., (max_shift - offset)..)); 119 | } 120 | 121 | out /= shifts as f32; 122 | 123 | out 124 | } else if args.split { 125 | args.split = false; 126 | 127 | let mut out = Tensor::zeros( 128 | [batch, self.config.sources.len() as i64, channels, length], 129 | (kind, device), 130 | ); 131 | let sum_weight = Tensor::zeros(length, (kind, device)); 132 | let segment_length = (Fraction::new(self.config.sample_rate as u64, 1u64) 133 | * args.segment) 134 | .to_f32() 135 | .unwrap() 136 | .round() as i64; 137 | 138 | let stride = ((1.0 - args.overlap) * segment_length as f32) as usize; 139 | let offsets = (0..length).step_by(stride); 140 | let _scale = stride as f32 / self.config.sample_rate as f32; 141 | let weight = Tensor::cat( 142 | &[ 143 | Tensor::arange_start(1, segment_length / 2 + 1, (kind, device)), 144 | Tensor::arange_start_step( 145 | segment_length - segment_length / 2, 146 | 0, 147 | -1, 148 | (kind, device), 149 | ), 150 | ], 151 | 0, 152 | ); 153 | 154 | assert_eq!(weight.size1().unwrap(), segment_length); 155 | 156 | let weight_max = weight.max(); 157 | let weight = (weight / weight_max).pow_tensor_scalar(args.transition_power); 158 | 159 | for offset in offsets { 160 | let chunk = TensorChunk::from_chunk(input, offset, Some(segment_length)); 161 | let chunk_out = self._apply(chunk, args.clone()); 162 | let chunk_length = chunk_out.size().pop().unwrap(); 163 | 164 | out.i((.., .., .., offset..offset + chunk_length)) 165 | .add_assign(weight.i(..chunk_length) * chunk_out); 166 | 167 | sum_weight 168 | .i(offset..offset + chunk_length) 169 | .add_assign(weight.i(..chunk_length)); 170 | } 171 | 172 | let sum_weight_min: f32 = sum_weight.min().try_into().unwrap(); 173 | 174 | assert!(sum_weight_min > 0.0); 175 | 176 | out /= sum_weight; 177 | out 178 | } else { 179 | let valid_length = 180 | (args.segment.to_f32().unwrap() * self.config.sample_rate as f32).round() as i64; 181 | 182 | let input = input.padded(valid_length); 183 | 184 | let out = tch::no_grad(|| self.module.forward_t(&input, false)); 185 | 186 | let out = center_trim(out, length); 187 | dbg!(out.size()); 188 | 189 | out 190 | } 191 | } 192 | } 193 | 194 | #[derive(Debug, Clone)] 195 | pub struct ApplyArgs { 196 | pub shifts: usize, 197 | pub split: bool, 198 | pub overlap: f32, 199 | pub transition_power: f64, 200 | pub device: Device, 201 | pub segment: Fraction, 202 | } 203 | 204 | #[derive(Clone, Copy, Debug)] 205 | struct TensorChunk<'a> { 206 | tensor: &'a Tensor, 207 | offset: i64, 208 | length: i64, 209 | } 210 | 211 | impl<'a> TensorChunk<'a> { 212 | fn new(tensor: &'a Tensor, offset: i64, length: Option) -> Self { 213 | let total_length = tensor.size().pop().expect("got tensor with 0 dimension"); 214 | 215 | assert!( 216 | offset < total_length, 217 | "offset cannot be greater than the length of the tensor" 218 | ); 219 | 220 | let length = length.map_or_else( 221 | || total_length - offset, 222 | |length| min(total_length - offset, length), 223 | ); 224 | 225 | Self { 226 | tensor, 227 | length, 228 | offset, 229 | } 230 | } 231 | 232 | fn from_chunk(chunk: TensorChunk<'a>, offset: i64, length: Option) -> Self { 233 | let total_length = chunk 234 | .size() 235 | .pop() 236 | .expect("got TensorChunk with 0 dimension"); 237 | 238 | assert!( 239 | offset < total_length, 240 | "offset cannot be greater than the length of the tensor" 241 | ); 242 | 243 | let length = match length { 244 | Some(length) => min(total_length - offset, length), 245 | None => total_length - offset, 246 | }; 247 | 248 | let tensor = chunk.tensor; 249 | let offset = chunk.offset + offset; 250 | 251 | Self { 252 | tensor, 253 | length, 254 | offset, 255 | } 256 | } 257 | 258 | fn size(&self) -> Vec { 259 | let mut size = self.tensor.size(); 260 | let length = size.last_mut().unwrap(); 261 | 262 | *length = self.length; 263 | 264 | size 265 | } 266 | 267 | fn padded(&self, target_length: i64) -> Tensor { 268 | let delta = target_length - self.length; 269 | let total_length = self.tensor.size().pop().unwrap(); 270 | 271 | assert!(delta >= 0); 272 | 273 | let start = self.offset - delta / 2; 274 | let end = start + target_length; 275 | 276 | let correct_start = max(0, start); 277 | let correct_end = min(total_length, end); 278 | 279 | let pad_left = correct_start - start; 280 | let pad_right = end - correct_end; 281 | 282 | 283 | 284 | self 285 | .tensor 286 | .i((.., .., correct_start..correct_end)) 287 | .f_pad([pad_left, pad_right], "constant", None) 288 | .unwrap() 289 | .to(Device::Mps) 290 | } 291 | } 292 | 293 | impl<'a> From<&'a Tensor> for TensorChunk<'a> { 294 | fn from(value: &'a Tensor) -> Self { 295 | Self::new(value, 0, None) 296 | } 297 | } 298 | 299 | fn center_trim(t: Tensor, length: i64) -> Tensor { 300 | let size = t.size().pop().unwrap(); 301 | 302 | let delta = size - length; 303 | 304 | assert!(delta >= 0); 305 | 306 | let start = delta / 2; 307 | let mut end = size - delta / 2; 308 | 309 | if (end - start) > length { 310 | end -= 1; 311 | } 312 | 313 | t.i((.., .., .., start..end)) 314 | } 315 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn( 2 | clippy::all, 3 | clippy::pedantic, 4 | clippy::nursery, 5 | clippy::cargo, 6 | )] 7 | #![ 8 | allow(clippy::single_call_fn) 9 | ] 10 | 11 | 12 | pub mod data; 13 | pub mod demucs; 14 | pub mod routes; 15 | pub mod util; 16 | 17 | pub use demucs::*; 18 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | use std::{error::Error, fs, io::stdout, sync::Arc}; 5 | use tauri::Manager; 6 | use tokio::sync::Mutex; 7 | use tracing::Level; 8 | use tracing_subscriber::fmt::format::FmtSpan; 9 | 10 | use stem_split::{ 11 | data::AppDb, 12 | demucs::{self, get_available_device, Demucs}, 13 | routes::{ 14 | project::{ 15 | __cmd__create_project, __cmd__get_all_projects, create_project, get_all_projects, 16 | }, 17 | split::{__cmd__split_stems, split_stems}, 18 | }, 19 | util::get_base_directory, 20 | }; 21 | 22 | 23 | #[tokio::main] 24 | async fn main() -> Result<(), Box> { 25 | // setup_global_subscriber(); 26 | 27 | fs::create_dir_all(get_base_directory().join("project_data")) 28 | .expect("Unable to ensure base_directory exists"); 29 | 30 | tauri::Builder::default() 31 | .plugin(tauri_plugin_drag::init()) 32 | .setup(|app| { 33 | println!("running setup"); 34 | let models_path = app 35 | .path_resolver() 36 | .resolve_resource("models/models.json") 37 | .expect("failed to resolve resource"); 38 | 39 | let models = demucs::models(&models_path)?; 40 | 41 | let model_info = 42 | demucs::find_model(models, "htdemucs").expect("model htdemucs is not available"); 43 | 44 | let device = get_available_device(); 45 | 46 | let model_path = app 47 | .path_resolver() 48 | .resolve_resource("models/htdemucs.pt") 49 | .expect("failed to resolve resource"); 50 | 51 | let model = Demucs::init(&model_path, &model_info, device)?; 52 | 53 | app.manage::(model); 54 | 55 | Ok(()) 56 | }) 57 | .manage(Mutex::from(AppDb::new(get_base_directory().join("db")))) 58 | .invoke_handler(tauri::generate_handler![ 59 | create_project, 60 | get_all_projects, 61 | split_stems, 62 | ]) 63 | .run(tauri::generate_context!()) 64 | .expect("error while running tauri application"); 65 | 66 | Ok(()) 67 | } 68 | 69 | fn setup_global_subscriber() { 70 | let file = std::fs::File::create("debug.log"); 71 | let _file = match file { 72 | Ok(file) => file, 73 | Err(error) => panic!("Error: {:?}", error), 74 | }; 75 | tracing_subscriber::fmt() 76 | .with_writer(Arc::new(stdout())) 77 | .with_span_events(FmtSpan::ENTER | FmtSpan::CLOSE) 78 | .with_max_level(Level::INFO) 79 | .init(); 80 | } 81 | -------------------------------------------------------------------------------- /src-tauri/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod project; 2 | pub mod split; 3 | 4 | use serde::Serialize; 5 | use snafu::Snafu; 6 | 7 | use crate::demucs; 8 | 9 | #[derive(Debug, Snafu)] 10 | #[snafu(visibility(pub))] 11 | pub enum Error { 12 | #[snafu(whatever, display("Unexpected Error: {message}: {source:?}"))] 13 | UnexpectedError { 14 | message: String, 15 | #[snafu(source(from(Box, Some)))] 16 | source: Option>, 17 | }, 18 | 19 | #[snafu(display("Failed to create project"))] 20 | ProjectCreationError, 21 | 22 | #[snafu(display("Failed to fetch projects"))] 23 | GetProjectsError, 24 | 25 | #[snafu(display("Failed to split track: {source}"))] 26 | StemSplitError { source: demucs::Error }, 27 | 28 | #[snafu(display("Failed to save stems"))] 29 | StemSaveError, 30 | } 31 | 32 | #[derive(Serialize)] 33 | struct ErrorWrapper { 34 | status: &'static str, 35 | message: String, 36 | } 37 | 38 | impl Serialize for Error { 39 | fn serialize(&self, serializer: S) -> std::result::Result 40 | where 41 | S: serde::Serializer, 42 | { 43 | let wrapper = ErrorWrapper { 44 | status: "error", 45 | message: self.to_string(), 46 | }; 47 | 48 | wrapper.serialize(serializer) 49 | } 50 | } 51 | 52 | type Result = std::result::Result; 53 | -------------------------------------------------------------------------------- /src-tauri/src/routes/project.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use tokio::sync::Mutex; 3 | 4 | use serde::{self, Deserialize, Serialize}; 5 | use tauri::State; 6 | 7 | use crate::data::{AppDb, Project}; 8 | 9 | use super::{Error, Result}; 10 | 11 | #[derive(Serialize, Deserialize)] 12 | #[serde(tag = "status")] 13 | pub enum CreateProjectResponse { 14 | #[serde(alias = "success")] 15 | Success { project: Project }, 16 | } 17 | 18 | #[derive(Serialize, Deserialize)] 19 | #[serde(tag = "status")] 20 | pub enum GetAllProjectsResponse { 21 | #[serde(alias = "success")] 22 | Success { projects: Vec }, 23 | } 24 | 25 | #[tauri::command] 26 | pub async fn create_project( 27 | audio_filepath: &str, 28 | app_db_mutex: State<'_, Mutex>, 29 | ) -> Result { 30 | let app_db = app_db_mutex.lock().await; 31 | 32 | app_db 33 | .create_project(PathBuf::from(audio_filepath)) 34 | .map_or(Err(Error::ProjectCreationError), |project| { 35 | Ok(CreateProjectResponse::Success { project }) 36 | }) 37 | } 38 | 39 | #[tauri::command] 40 | pub async fn get_all_projects( 41 | app_db_mutex: State<'_, Mutex>, 42 | ) -> Result { 43 | let app_db = app_db_mutex.lock().await; 44 | app_db 45 | .get_projects() 46 | .map_or(Err(Error::ProjectCreationError), |projects| { 47 | Ok(GetAllProjectsResponse::Success { projects }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src-tauri/src/routes/split.rs: -------------------------------------------------------------------------------- 1 | use snafu::ResultExt; 2 | use tokio::sync::Mutex; 3 | 4 | use serde::{self, Deserialize, Serialize}; 5 | use tauri::State; 6 | 7 | use crate::{ 8 | data::AppDb, 9 | demucs::{split_track, Demucs}, 10 | routes::StemSplitSnafu, 11 | util::get_base_directory, 12 | }; 13 | 14 | use super::{Error, Result}; 15 | 16 | #[derive(Serialize, Deserialize)] 17 | #[serde(tag = "status")] 18 | pub enum SplitStemsResponse { 19 | #[serde(alias = "success")] 20 | Success { stems: Vec }, 21 | } 22 | 23 | #[tauri::command] 24 | #[tracing::instrument(skip(app_db_mutex, model))] 25 | pub async fn split_stems( 26 | project_id: &str, 27 | app_db_mutex: State<'_, Mutex>, 28 | model: State<'_, Demucs>, 29 | ) -> Result { 30 | let project_dir = get_base_directory().join("project_data").join(project_id); 31 | 32 | let song_path = project_dir.join("main.mp3"); // We're dealing with just MP3 for now. 33 | 34 | let stem_paths = split_track(&model, &song_path, &project_dir).context(StemSplitSnafu)?; 35 | 36 | let stems = stem_paths 37 | .clone() 38 | .into_iter() 39 | .map(|p| p.to_string_lossy().to_string()) 40 | .collect(); 41 | 42 | let app_db = app_db_mutex.lock().await; 43 | 44 | app_db 45 | .add_stems_to_project(String::from(project_id), stem_paths) 46 | .map_or(Err(Error::StemSaveError), |_| { 47 | Ok(SplitStemsResponse::Success { stems }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src-tauri/src/util.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | use std::time::{SystemTime, UNIX_EPOCH}; 4 | 5 | pub fn current_unix_timestamp() -> i64 { 6 | match SystemTime::now().duration_since(UNIX_EPOCH) { 7 | Ok(duration) => duration.as_secs() as i64, 8 | Err(_) => 0, // should not happen 9 | } 10 | } 11 | 12 | fn get_home_directory() -> PathBuf { 13 | let homedir_path_result = match env::consts::OS { 14 | "windows" => env::var("USERPROFILE").or_else(|_| { 15 | let home_drive = env::var("HOMEDRIVE").unwrap_or_default(); 16 | let home_path = env::var("HOMEPATH").unwrap_or_default(); 17 | if home_drive.is_empty() && home_path.is_empty() { 18 | Err(env::VarError::NotPresent) 19 | } else { 20 | Ok(home_drive + &home_path) 21 | } 22 | }), 23 | _ => env::var("HOME"), 24 | }; 25 | 26 | PathBuf::from(homedir_path_result.expect("No home directory found.")) // No way to recover from this. 27 | } 28 | 29 | pub fn get_base_directory() -> PathBuf { 30 | let homedir = get_home_directory(); 31 | homedir.join("stemsplit") 32 | } 33 | 34 | pub fn generate_random_string() -> String { 35 | let now = SystemTime::now(); 36 | let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); // Handle this more gracefully in a real app 37 | let timestamp = since_the_epoch.as_secs(); // Get the current UNIX timestamp as seconds 38 | 39 | // Convert the timestamp to a hexadecimal string 40 | let hex_string = format!("{:x}", timestamp); 41 | 42 | // Take the last 8 characters to ensure the string is of the desired length 43 | // This is a simplistic approach and might need adjustment based on your needs 44 | hex_string 45 | .chars() 46 | .rev() 47 | .take(8) 48 | .collect::() 49 | .chars() 50 | .rev() 51 | .collect() 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "npm run dev", 4 | "beforeBuildCommand": "npm run build", 5 | "devPath": "http://localhost:1420", 6 | "distDir": "../dist" 7 | }, 8 | "package": { 9 | "productName": "Tune Prism", 10 | "version": "0.0.0" 11 | }, 12 | "tauri": { 13 | "allowlist": { 14 | "path": { 15 | "all": true 16 | }, 17 | "all": false, 18 | "shell": { 19 | "all": false, 20 | "open": true 21 | }, 22 | "protocol": { 23 | "all": true, 24 | "asset": true, 25 | "assetScope": ["**"] 26 | }, 27 | "fs": { 28 | "all": true, 29 | "readFile": true, 30 | "writeFile": true, 31 | "readDir": true, 32 | "copyFile": true, 33 | "createDir": true, 34 | "removeDir": true, 35 | "removeFile": true, 36 | "renameFile": true, 37 | "exists": true 38 | } 39 | }, 40 | "bundle": { 41 | "active": true, 42 | "targets": "all", 43 | "identifier": "com.splitter.dev", 44 | "icon": [ 45 | "icons/32x32.png", 46 | "icons/128x128.png", 47 | "icons/128x128@2x.png", 48 | "icons/icon.icns", 49 | "icons/icon.ico" 50 | ], 51 | "macOS": { 52 | "frameworks": [ 53 | "../libtorch/lib/libtorch_cpu.dylib", 54 | "../libtorch/lib/libtorch.dylib", 55 | "../libtorch/lib/libc10.dylib", 56 | "/opt/homebrew/Cellar/libomp/18.1.2/lib/libomp.dylib" 57 | ] 58 | }, 59 | "resources": [ 60 | "./models/*", 61 | "./resources/*" 62 | ] 63 | }, 64 | "security": { 65 | "csp": null 66 | }, 67 | "windows": [ 68 | { 69 | "fullscreen": false, 70 | "resizable": true, 71 | "title": "TunePrism", 72 | "width": 800, 73 | "height": 700 74 | } 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from "@chakra-ui/react" 2 | import Project from "./components/project/project" 3 | import Sidebar from "./components/sidebar/Sidebar" 4 | import { 5 | BrowserRouter, 6 | Route, 7 | Routes, 8 | } from "react-router-dom" 9 | 10 | const MainScreen = () => { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | function App() { 20 | return ( 21 | 22 | 28 | 29 | 30 | } /> 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/project/TrackDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Grid, GridItem } from "@chakra-ui/react" 2 | 3 | const TrackDetailItem = (props: { heading: string; children: any }) => { 4 | return ( 5 | 6 | 7 | 11 | {props.heading} 12 | 13 | 16 | {props.children} 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | const TrackDetails = () => { 24 | return ( 25 | 30 | 36 | Details 37 | 38 | 42 | 172 43 | C major 44 | 45 | 46 | ) 47 | } 48 | 49 | export default TrackDetails 50 | -------------------------------------------------------------------------------- /src/components/project/project.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Box, 4 | IconButton, 5 | DarkMode, 6 | Divider, 7 | Button, 8 | Grid, 9 | GridItem 10 | } from "@chakra-ui/react" 11 | import { FiPlay, FiPause } from "react-icons/fi" 12 | import { FaAssistiveListeningSystems } from "react-icons/fa"; 13 | import { useEffect, useState } from "react" 14 | import { appWindow } from "@tauri-apps/api/window" 15 | import { convertFileSrc } from "@tauri-apps/api/tauri" 16 | import { getFileTypeFromPath, getFilenameFromPath } from "../../util/project" 17 | import WavesurferPlayer from "@wavesurfer/react" 18 | import TrackDetails from "./TrackDetails" 19 | import { createProject } from "../../functions/project"; 20 | import { Project } from "../../store/project/types"; 21 | import useProjectStore from "../../store/project"; 22 | import { splitStems } from "../../functions/split"; 23 | import { startDrag } from "@crabnebula/tauri-plugin-drag"; 24 | import { resolveResource } from '@tauri-apps/api/path' 25 | 26 | type Color = 'teal' | 'purple' | 'cyan' | 'green' | 'yellow' | 'blue' | 'blue2' | 'red' 27 | 28 | type ColorScheme = { 29 | background: string 30 | active?: string, 31 | played: string 32 | } 33 | 34 | const ACCEPTED_FILE_TYPES: Record = { 35 | mp3: true 36 | } 37 | 38 | const WAVEFORM_COLOR_SCHEMES: Record = { 39 | teal: { 40 | background: '#2C7A7B', // 600 41 | active: '#81E6D9', // 200 42 | played: '#234E52' // 800 43 | }, 44 | cyan: { 45 | background: '#00A3C4', // 600 46 | active: '#9DECF9', // 200 47 | played: '#086F83' // 800 48 | }, 49 | blue: { 50 | background: '#2B6CB0', // 600 51 | active: '#90CDF4', // 200 52 | played: '#2A4365' //800 53 | }, 54 | blue2: { 55 | background: '#29B6F6', // 600 56 | active: '#81D4FA', // 200 57 | played: '#01579B' // 800 58 | }, 59 | 60 | purple: { 61 | background: '#6B46C1', // 600 62 | active: '#D6BCFA', // 200 63 | played: '#44337A' //800 64 | }, 65 | yellow: { 66 | background: '#B7791F', // 600 67 | active: '#FAF089', // 200 68 | played: '#744210' //800 69 | }, 70 | red: { 71 | background: '#C53030', // 600 72 | active: '#FEB2B2', // 200 73 | played: '#822727' //800 74 | }, 75 | green: { 76 | background: '#2F855A', 77 | active: '#9AE6B4', 78 | played: '#22543D', 79 | }, 80 | } 81 | 82 | // const COLOR_ORDER = Object.keys(WAVEFORM_COLOR_SCHEMES) 83 | const COLOR_ORDER: Color[] = ['green', 'yellow', 'red', 'blue'] 84 | 85 | type WaveformPlayerProps = { 86 | path: string 87 | filePath?: string 88 | color: Color 89 | height?: number 90 | barWidth?: number 91 | } 92 | 93 | const WaveformPlayer = ({ 94 | path, 95 | filePath, 96 | color, 97 | height = 40, 98 | barWidth = 3 99 | }: WaveformPlayerProps) => { 100 | const [wavesurfer, setWavesurfer] = useState(null) 101 | const [isPlaying, setIsPlaying] = useState(false) 102 | const [iconPath, setIconPath] = useState('') 103 | 104 | useEffect(() => { 105 | console.log('we in here') 106 | // Need to do this whole thing in a useEffect because this fucking function is async. 107 | // Useless re-render, smh. 108 | resolveResource(`resources/${color}_stem_icon.png`) 109 | .then(result => { 110 | console.log('result:', result) 111 | setIconPath(result) 112 | return 113 | }) 114 | .catch(e => console.log('resource error:', e)) 115 | }, []) 116 | 117 | const { background, active, played } = WAVEFORM_COLOR_SCHEMES[color] 118 | 119 | 120 | const onReady = (ws: any) => { 121 | setWavesurfer(ws) 122 | setIsPlaying(false) 123 | } 124 | 125 | const onPlayPause = () => { 126 | wavesurfer && wavesurfer.playPause() 127 | } 128 | 129 | function handleDrag() { 130 | console.log('drag start', filePath) 131 | if (!filePath) { 132 | return 133 | } 134 | 135 | console.log('iconPath:', iconPath) 136 | // startDrag({ item: [filePath], icon: '/Users/dushyant/Projects/stem-split/src-tauri/icons/128x128.png' }) 137 | startDrag({ item: [filePath], icon: iconPath }) 138 | } 139 | 140 | return ( 141 | 149 | 154 | 155 | : } 157 | aria-label="play-or-pause" 158 | onClick={onPlayPause} 159 | size="sm" 160 | // colorScheme={color} 161 | /> 162 | 163 | 164 | 165 | setIsPlaying(true)} 173 | onPause={() => setIsPlaying(false)} 174 | barWidth={barWidth} 175 | progressColor={played} 176 | /> 177 | 178 | 179 | ) 180 | } 181 | 182 | type TrackViewProps = { 183 | project: Project 184 | } 185 | const TrackView = ({ 186 | project 187 | }: TrackViewProps) => { 188 | const addStems = useProjectStore(state => state.addStems) 189 | const projectFilePath = `${project.base_dir}/project_data/${project._id}/main.mp3` 190 | const webFilePath = convertFileSrc(projectFilePath) 191 | const [extractStemsLoading, setExtractStemsLoading] = useState(false) 192 | 193 | async function handleStemExtractClick() { 194 | setExtractStemsLoading(true) 195 | splitStems(project._id) 196 | .then((stems) => { 197 | addStems(project._id, stems) 198 | }) 199 | .catch(e => console.log('Error splitting stems:', e)) 200 | .finally(() => setExtractStemsLoading(false)) 201 | } 202 | 203 | console.log('stem_paths', project.stem_paths) 204 | 205 | return ( 206 | 213 | 214 | {project.name} 215 | 216 | 222 | 227 | 231 | 237 | Original track 238 | 239 | 240 | 241 | 244 | 245 | 246 | 247 | 251 | 252 | 259 | Stems 260 | 261 | 262 | {project.stem_paths.length !== 0 && ( 263 | 268 | {project.stem_paths.map((stemPath, idx) => ( 269 | 270 | 274 | 281 | {getFilenameFromPath(stemPath)} 282 | 283 | 290 | 291 | 292 | ))} 293 | 294 | )} 295 | 296 | 297 | 305 | 306 | 307 | 308 | 309 | ) 310 | } 311 | 312 | const ProjectController = () => { 313 | const addProject = useProjectStore(state => state.addProject) 314 | const currentProject = useProjectStore(state => state.projects[state.selectedProjectId]) 315 | 316 | // Hover control state 317 | const [_fileOk, setFileOk] = useState(true) 318 | const [_hovering, setHovering] = useState(false) 319 | 320 | useEffect(() => { 321 | const unlistenPromise = appWindow.onFileDropEvent((event) => { 322 | if (event.payload.type === "hover") { 323 | setHovering(true) 324 | const fileType = getFileTypeFromPath(event.payload.paths[0]) 325 | if (ACCEPTED_FILE_TYPES[fileType]) { 326 | setFileOk(true) 327 | } else { 328 | setFileOk(false) 329 | } 330 | 331 | console.log("event:", event) 332 | } else if (event.payload.type === "drop") { 333 | setHovering(false) 334 | console.log("User dropped", event.payload.paths) 335 | const fileType = getFileTypeFromPath(event.payload.paths[0]) 336 | if (ACCEPTED_FILE_TYPES[fileType]) { 337 | setFileOk(true) 338 | const filePath = event.payload.paths[0] 339 | 340 | createProject(filePath) 341 | .then(project => { 342 | addProject(project) 343 | }) 344 | } else { 345 | setFileOk(false) 346 | } 347 | } else { 348 | setHovering(false) 349 | } 350 | }) 351 | 352 | return () => { 353 | unlistenPromise.then((u) => u()) 354 | } 355 | }, []) 356 | 357 | if (!currentProject) { 358 | return ( 359 | 364 | 365 | You have no projects. 366 | 367 | 372 | Drop any track in this window to get started. 373 | 374 | 375 | ) 376 | } 377 | 378 | return ( 379 | 382 | ) 383 | } 384 | 385 | export default ProjectController 386 | -------------------------------------------------------------------------------- /src/components/search/SongSearch.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Input } from "@chakra-ui/react" 2 | import { useState } from "react" 3 | 4 | const SongSearch = () => { 5 | const [input, setInput] = useState('') 6 | 7 | return ( 8 | 12 | setInput(e.target.value)} 19 | /> 20 | 21 | ) 22 | } 23 | 24 | export default SongSearch -------------------------------------------------------------------------------- /src/components/sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | } from "@chakra-ui/react" 5 | import { useEffect, useMemo } from "react" 6 | import { getAllProjects } from "../../functions/project" 7 | import useProjectStore from "../../store/project" 8 | import { Project } from "../../store/project/types" 9 | import { formatDate } from "../../util/misc" 10 | 11 | type ProjectItemProps = { 12 | project: Project 13 | active: boolean 14 | onClick: () => void 15 | } 16 | 17 | const ProjectItem = ({ 18 | project, 19 | active = false, 20 | onClick, 21 | }: ProjectItemProps) => { 22 | return ( 23 | 37 | 47 | {project.name} 48 | 49 | {formatDate(project.created_at)} 50 | 51 | ) 52 | } 53 | 54 | const Sidebar = () => { 55 | const setProjects = useProjectStore((state) => state.setProjects) 56 | const selectProject = useProjectStore((state) => state.selectProject) 57 | const projects = useProjectStore((state) => state.projects) 58 | const selectedProjectId = useProjectStore( 59 | (state) => state.selectedProjectId 60 | ) 61 | 62 | useEffect(() => { 63 | getAllProjects().then((res) => { 64 | setProjects(res) 65 | }) 66 | }, []) 67 | 68 | const recentProjects = useMemo( 69 | () => 70 | Object.values(projects).sort((a, b) => b.created_at - a.created_at), 71 | [projects] 72 | ) 73 | 74 | return ( 75 | 89 | 90 | History 91 | 92 | 98 | 104 | {recentProjects.map((project) => ( 105 | selectProject(project._id)} 110 | /> 111 | ))} 112 | 113 | 114 | 115 | ) 116 | } 117 | 118 | export default Sidebar 119 | -------------------------------------------------------------------------------- /src/functions/project.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/tauri" 2 | import { Project } from "../store/project/types" 3 | 4 | export async function createProject(filepath: string): Promise { 5 | const result: any = await invoke("create_project", { 6 | audioFilepath: filepath, 7 | }) 8 | if (result.status === "Success") { 9 | return result.project as Project 10 | } else { 11 | console.log("Unable to create project:", result) 12 | throw new Error("Unable to create project") 13 | } 14 | } 15 | 16 | export async function getAllProjects(): Promise { 17 | const result: any = await invoke("get_all_projects", {}) 18 | if (result.status === "Success") { 19 | return result.projects as Project[] 20 | } else { 21 | console.log("Unable to get projects", result) 22 | throw new Error("Unable to get projects.") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/functions/split.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/tauri" 2 | 3 | type StemSplitSuccessResult = { 4 | status: 'Success', 5 | stems: string[] 6 | } 7 | 8 | type StemSplitErrorResult = { 9 | status: 'Error', 10 | message: string 11 | } 12 | 13 | export type StemSplitResult = StemSplitSuccessResult | StemSplitErrorResult 14 | 15 | export async function splitStems(projectId: string): Promise { 16 | const result: StemSplitResult = await invoke('split_stems', { 17 | projectId: projectId 18 | }) 19 | 20 | if (result.status === 'Success') { 21 | return result.stems 22 | } else{ 23 | throw new Error(result.message) 24 | } 25 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import App from "./App"; 3 | import { ChakraProvider } from "@chakra-ui/react"; 4 | import './styles.css' 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/store/project/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { Project, StoreState } from './types' 3 | 4 | const useProjectStore = create()((set) => ({ 5 | projects: {}, 6 | selectedProjectId: '', 7 | 8 | setProjects: (projects) => { 9 | set((state) => { 10 | const projMap: Record = {} 11 | for (const proj of projects) { 12 | projMap[proj._id] = proj 13 | } 14 | 15 | const latestProjectId = projects.reduce((max, curr) => { 16 | return max.created_at > curr.created_at ? max : curr 17 | })._id 18 | 19 | return { 20 | ...state, 21 | selectedProjectId: latestProjectId, 22 | projects: projMap 23 | } 24 | }) 25 | }, 26 | addProject: (project) => { 27 | set((state) => { 28 | const newProjects = {...state.projects} 29 | newProjects[project._id] = project 30 | return { 31 | ...state, 32 | selectedProjectId: project._id, 33 | projects: newProjects 34 | } 35 | }) 36 | }, 37 | deleteProject: (id) => { 38 | set((state) => { 39 | const newProjects = {...state.projects} 40 | delete newProjects[id] 41 | return { 42 | ...state, 43 | projects: newProjects 44 | } 45 | }) 46 | }, 47 | selectProject: (id) => { 48 | set((state) => { 49 | if (!state.projects[id]) { 50 | console.log('Project does not exist, what the fuck happened?') 51 | return state 52 | } 53 | 54 | return { 55 | ...state, 56 | selectedProjectId: id 57 | } 58 | }) 59 | }, 60 | addStems: (id, stems) => { 61 | set((state) => { 62 | if (!state.projects[id]) { 63 | console.log('project does not exist, what the fuck happened?') 64 | return state 65 | } 66 | 67 | const newProjects = { ...state.projects } 68 | const newProject = { ...newProjects[id] } 69 | newProject.stem_paths = stems 70 | newProjects[id] = newProject 71 | 72 | return { 73 | ...state, 74 | projects: newProjects 75 | } 76 | }) 77 | } 78 | })) 79 | 80 | export default useProjectStore -------------------------------------------------------------------------------- /src/store/project/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Project = { 2 | _id: string 3 | base_dir: string 4 | created_at: number 5 | name: string 6 | stem_paths: string[] 7 | } 8 | 9 | export interface StoreState { 10 | projects: Record, 11 | selectedProjectId: string, 12 | addProject: (project: Project) => void, 13 | setProjects: (projects: Project[]) => void, 14 | deleteProject: (projectId: string) => void, 15 | selectProject: (projectId: string) => void, 16 | addStems: (projectId: string, stems: string[]) => void 17 | } -------------------------------------------------------------------------------- /src/store/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { PaymentDrawerIntent, StoreState } from './types' 3 | 4 | const useUiStore = create()((set) => ({ 5 | paymentDrawerOpen: false, 6 | paymentDrawerIntent: undefined, 7 | 8 | openPaymentDrawer: (intent: PaymentDrawerIntent) => { 9 | set((state) => { 10 | return { 11 | ...state, 12 | paymentDrawerIntent: intent, 13 | paymentDrawerOpen: true 14 | } 15 | }) 16 | }, 17 | 18 | closePaymentDrawer: () => { 19 | set((state) => { 20 | return { 21 | ...state, 22 | paymentDrawerOpen: false 23 | } 24 | }) 25 | } 26 | })) 27 | 28 | export default useUiStore -------------------------------------------------------------------------------- /src/store/ui/types.d.ts: -------------------------------------------------------------------------------- 1 | type PaymentDrawerIntent = 'TRIAL_EXPIRED' | 'USER_INITIATED' 2 | 3 | export interface StoreState { 4 | paymentDrawerOpen: boolean 5 | paymentDrawerIntent: PaymentDrawerIntent | undefined 6 | openPaymentDrawer: (intent: PaymentDrawerIntent) => void 7 | closePaymentDrawer: () => void 8 | } -------------------------------------------------------------------------------- /src/store/user/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { StoreState } from './types' 3 | 4 | const useUserStore = create()((set) => ({ 5 | user: { 6 | name: '', 7 | email: '', 8 | locale: 'en', 9 | tier: 'FREE' 10 | }, 11 | loggedIn: false, 12 | 13 | login: (user) => { 14 | set((state) => { 15 | return { 16 | ...state, 17 | user: user, 18 | loggedIn: true 19 | } 20 | }) 21 | }, 22 | 23 | logout: () => { 24 | set((state) => { 25 | return { 26 | ...state, 27 | user: { 28 | name: '', 29 | email: '', 30 | locale: 'en', 31 | tier: 'FREE' 32 | }, 33 | loggedIn: false 34 | } 35 | }) 36 | }, 37 | 38 | setTier: (tier) => { 39 | set((state) => { 40 | return { 41 | ...state, 42 | user: { 43 | ...state.user, 44 | tier: tier 45 | } 46 | } 47 | }) 48 | } 49 | })) 50 | 51 | export default useUserStore -------------------------------------------------------------------------------- /src/store/user/types.d.ts: -------------------------------------------------------------------------------- 1 | export type UserTier = 'FREE' | 'FULL' 2 | 3 | export type User = { 4 | name: string 5 | locale: string 6 | email: string 7 | tier: UserTier 8 | } 9 | 10 | export interface StoreState { 11 | loggedIn: boolean 12 | user: User 13 | login: (user: User) => void, 14 | logout: () => void, 15 | setTier: (tier: UserTier) => void 16 | } -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* @font-face { 2 | font-family: 'DM Sans'; 3 | src: url('./assets/dmsans.ttf') format('truetype'); 4 | font-weight: 100 900; 5 | font-style: normal; 6 | } */ 7 | 8 | :root { 9 | font-family: "DM Sans", Inter, Avenir, Helvetica, Arial, sans-serif; 10 | font-family: "DM Sans"; 11 | font-size: 16px; 12 | line-height: 24px; 13 | font-weight: 400; 14 | 15 | color: #0f0f0f; 16 | /* background-color: #1C1C1E; */ 17 | background: linear-gradient( 18 | 0deg, 19 | rgba(28, 28, 30, 0.9), 20 | rgba(28, 28, 30, 0.9) 21 | ), 22 | url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); 23 | 24 | font-synthesis: none; 25 | text-rendering: optimizeLegibility; 26 | -webkit-font-smoothing: antialiased; 27 | -moz-osx-font-smoothing: grayscale; 28 | -webkit-text-size-adjust: 100%; 29 | } 30 | 31 | body { 32 | position: fixed; 33 | width: 100%; 34 | height: 100%; 35 | overflow: hidden; 36 | } 37 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src/theme.ts -------------------------------------------------------------------------------- /src/util/misc.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(timestamp: number): string { 2 | const date = new Date(timestamp * 1000) // Convert seconds to milliseconds 3 | const day = date.getDate() // No padding for day 4 | const months = [ 5 | "Jan", 6 | "Feb", 7 | "Mar", 8 | "Apr", 9 | "May", 10 | "Jun", 11 | "Jul", 12 | "Aug", 13 | "Sep", 14 | "Oct", 15 | "Nov", 16 | "Dec", 17 | ] 18 | const month = months[date.getMonth()] // Get the abbreviated month 19 | const year = date.getFullYear() 20 | return `${day} ${month}, ${year}` 21 | } 22 | 23 | export function formatName(name: string): string { 24 | const parts = name.trim().split(/\s+/) 25 | if (parts.length === 2 && parts[0].length === 1 && parts[0].endsWith(".")) { 26 | // Name is an initial followed by a surname, return as is 27 | return name 28 | } else if (parts.length >= 2) { 29 | // Take the first name and the first letter of the last name 30 | return `${parts[0]} ${parts[1].charAt(0)}` 31 | } 32 | return name // Return the name if it doesn't fit the criteria above 33 | } 34 | -------------------------------------------------------------------------------- /src/util/project.ts: -------------------------------------------------------------------------------- 1 | export function getFileTypeFromPath(filePath: string): string { 2 | const parts = filePath.split('.') 3 | try { 4 | return parts[parts.length - 1] 5 | } catch (e) { 6 | console.log('File probably has no extension') 7 | return '' 8 | } 9 | } 10 | 11 | export function getFilenameFromPath(filePath: string): string { 12 | try { 13 | const parts = filePath.split('/') 14 | return parts[parts.length - 1] 15 | } catch (e) { 16 | return '' 17 | } 18 | } -------------------------------------------------------------------------------- /src/util/spotify.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedonhermdev/tune-prism/50f3d3ac79be4664b3aed87235d521de8841794e/src/util/spotify.ts -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(async () => ({ 6 | plugins: [react()], 7 | 8 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 9 | // 10 | // 1. prevent vite from obscuring rust errors 11 | clearScreen: false, 12 | // 2. tauri expects a fixed port, fail if that port is not available 13 | server: { 14 | port: 1420, 15 | strictPort: true, 16 | watch: { 17 | // 3. tell vite to ignore watching `src-tauri` 18 | ignored: ["**/src-tauri/**"], 19 | }, 20 | }, 21 | })); 22 | --------------------------------------------------------------------------------