echovault-flac-enhancer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ strategy:
10
+ matrix:
11
+ os: [ubuntu-latest]
12
+ python-version: ["3.10", "3.11", "3.12"]
13
+ include:
14
+ - os: windows-latest
15
+ python-version: "3.12"
16
+ runs-on: ${{ matrix.os }}
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - run: pip install -e .[dev]
23
+ - run: ruff check .
24
+ - run: black --check .
25
+ - run: pytest -m "not slow"
@@ -0,0 +1,21 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch: {}
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - run: pip install build
20
+ - run: python -m build
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.1.0] - Unreleased
7
+
8
+ ### Added
9
+
10
+ - Initial release: `--setup`, `--check`, `--file-name`, `--folder`
11
+ (`--recursive`, `--skip-existing`, `--output-dir`) CLI commands.
12
+ - Model download/verification against a GitHub Release asset, ported from
13
+ EchoVault's own `downloader.js`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EchoVault contributors
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.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: echovault-flac-enhancer
3
+ Version: 0.1.0
4
+ Summary: Standalone CLI for batch-enhancing lossy audio to FLAC via EchoVault's ONNX model
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mutagen>=1.47
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: onnxruntime==1.20.1
11
+ Requires-Dist: platformdirs>=4.0
12
+ Requires-Dist: scipy>=1.11
13
+ Requires-Dist: soundfile>=0.13.1
14
+ Requires-Dist: tqdm>=4.66
15
+ Provides-Extra: dev
16
+ Requires-Dist: black; extra == 'dev'
17
+ Requires-Dist: build; extra == 'dev'
18
+ Requires-Dist: pytest-mock; extra == 'dev'
19
+ Requires-Dist: pytest>=8; extra == 'dev'
20
+ Requires-Dist: ruff; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # echovault-flac-enhancer
24
+
25
+ Standalone CLI for bulk-enhancing lossy audio (MP3/AAC/OGG/WMA) to FLAC using
26
+ the same ONNX model EchoVault's desktop app uses, independent of Electron.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install echovault-flac-enhancer
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ # One-time: download the model and verify everything works
38
+ echovault-flac-enhancer --setup
39
+
40
+ # Check runtime/deps/model status
41
+ echovault-flac-enhancer --check
42
+
43
+ # Enhance a single file
44
+ echovault-flac-enhancer --file-name path/to/track.mp3
45
+
46
+ # Enhance every lossy track in a folder
47
+ echovault-flac-enhancer --folder path/to/music [--recursive] [--skip-existing] [--output-dir DIR]
48
+ ```
49
+
50
+ Outputs are written as `<original-stem>.enhanced.flac` alongside the source
51
+ (or under `--output-dir`, mirroring the source's relative subpath).
52
+
53
+ The model (~107MB) is cached at:
54
+
55
+ - Linux/macOS: `~/.cache/echovault-flac-enhancer/model/`
56
+ - Windows: `%LOCALAPPDATA%\echovault-flac-enhancer\model\`
57
+
58
+ ## Non-goals (v1)
59
+
60
+ - No GUI — CLI only.
61
+ - No parallel/multi-worker inference (`--workers` must be `1`).
62
+ - No mid-batch resume — re-run with `--skip-existing` to pick up where a
63
+ killed batch left off.
64
+ - `.m4a` files are not auto-globbed by `--folder` (ambiguous AAC vs. ALAC
65
+ codec-in-container — pass an `.m4a` explicitly via `--file-name` if you're
66
+ sure it's lossy AAC).
67
+
68
+ ## Releasing
69
+
70
+ Before `--setup` works for end users, a maintainer must create a GitHub
71
+ Release on this repo and upload `model.onnx` + `config.json` as release
72
+ assets, matching the URL/hashes pinned in
73
+ `echovault_flac_enhancer/data/manifest.json`.
@@ -0,0 +1,51 @@
1
+ # echovault-flac-enhancer
2
+
3
+ Standalone CLI for bulk-enhancing lossy audio (MP3/AAC/OGG/WMA) to FLAC using
4
+ the same ONNX model EchoVault's desktop app uses, independent of Electron.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install echovault-flac-enhancer
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ # One-time: download the model and verify everything works
16
+ echovault-flac-enhancer --setup
17
+
18
+ # Check runtime/deps/model status
19
+ echovault-flac-enhancer --check
20
+
21
+ # Enhance a single file
22
+ echovault-flac-enhancer --file-name path/to/track.mp3
23
+
24
+ # Enhance every lossy track in a folder
25
+ echovault-flac-enhancer --folder path/to/music [--recursive] [--skip-existing] [--output-dir DIR]
26
+ ```
27
+
28
+ Outputs are written as `<original-stem>.enhanced.flac` alongside the source
29
+ (or under `--output-dir`, mirroring the source's relative subpath).
30
+
31
+ The model (~107MB) is cached at:
32
+
33
+ - Linux/macOS: `~/.cache/echovault-flac-enhancer/model/`
34
+ - Windows: `%LOCALAPPDATA%\echovault-flac-enhancer\model\`
35
+
36
+ ## Non-goals (v1)
37
+
38
+ - No GUI — CLI only.
39
+ - No parallel/multi-worker inference (`--workers` must be `1`).
40
+ - No mid-batch resume — re-run with `--skip-existing` to pick up where a
41
+ killed batch left off.
42
+ - `.m4a` files are not auto-globbed by `--folder` (ambiguous AAC vs. ALAC
43
+ codec-in-container — pass an `.m4a` explicitly via `--file-name` if you're
44
+ sure it's lossy AAC).
45
+
46
+ ## Releasing
47
+
48
+ Before `--setup` works for end users, a maintainer must create a GitHub
49
+ Release on this repo and upload `model.onnx` + `config.json` as release
50
+ assets, matching the URL/hashes pinned in
51
+ `echovault_flac_enhancer/data/manifest.json`.
@@ -0,0 +1,3 @@
1
+ """echovault-flac-enhancer: standalone CLI wrapper around EchoVault's ONNX FLAC enhancer."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,181 @@
1
+ """Single-file and batch enhancement: spawns inference.py, parses its stdout/stderr
2
+ contract, and drives sequential batch processing over a folder of tracks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Callable, NamedTuple
12
+
13
+ LOSSY_EXTENSIONS = {
14
+ ".mp3",
15
+ ".aac",
16
+ ".ogg",
17
+ ".wma",
18
+ } # .m4a excluded: ambiguous AAC-vs-ALAC codec-in-container
19
+
20
+ PROGRESS_RE = re.compile(r"^PROGRESS\s+(\d+)$")
21
+ DONE_RE = re.compile(r"^DONE\s+(.+)$")
22
+ ERROR_RE = re.compile(r"ERROR\s+(\w+)\s*(.*)")
23
+
24
+ EXIT_CODE_FALLBACK = {
25
+ 1: "GENERIC",
26
+ 2: "INPUT_READ_FAILED",
27
+ 3: "MODEL_NOT_FOUND",
28
+ 4: "ORT_INIT_FAILED",
29
+ }
30
+
31
+
32
+ class InferenceResult(NamedTuple):
33
+ success: bool
34
+ output_path: Path | None
35
+ error_code: str | None
36
+ error_message: str | None
37
+
38
+
39
+ def run_single_file(
40
+ inference_script: Path,
41
+ model_path: Path,
42
+ config_path: Path,
43
+ input_path: Path,
44
+ output_path: Path,
45
+ on_progress: Callable[[int], None] = lambda pct: None,
46
+ ) -> InferenceResult:
47
+ proc = subprocess.Popen(
48
+ [
49
+ sys.executable,
50
+ str(inference_script),
51
+ "--model",
52
+ str(model_path),
53
+ "--config",
54
+ str(config_path),
55
+ "--input",
56
+ str(input_path),
57
+ "--output",
58
+ str(output_path),
59
+ "--provider",
60
+ "auto",
61
+ ],
62
+ stdout=subprocess.PIPE,
63
+ stderr=subprocess.PIPE,
64
+ text=True,
65
+ )
66
+
67
+ stderr_lines: list[str] = []
68
+
69
+ def drain_stderr() -> None:
70
+ for line in proc.stderr:
71
+ stderr_lines.append(line.rstrip("\n"))
72
+
73
+ stderr_thread = threading.Thread(target=drain_stderr, daemon=True)
74
+ stderr_thread.start()
75
+
76
+ done_path: Path | None = None
77
+ for line in proc.stdout:
78
+ line = line.strip()
79
+ if m := PROGRESS_RE.match(line):
80
+ on_progress(int(m.group(1)))
81
+ elif m := DONE_RE.match(line):
82
+ done_path = Path(m.group(1))
83
+
84
+ returncode = proc.wait()
85
+ stderr_thread.join()
86
+
87
+ if returncode == 0:
88
+ if done_path is not None and done_path != output_path:
89
+ print(
90
+ f"WARN inference reported DONE {done_path}, expected {output_path}",
91
+ file=sys.stderr,
92
+ )
93
+ return InferenceResult(
94
+ success=True, output_path=output_path, error_code=None, error_message=None
95
+ )
96
+
97
+ error_code = None
98
+ error_message = None
99
+ for line in stderr_lines:
100
+ if m := ERROR_RE.search(line):
101
+ error_code = m.group(1)
102
+ error_message = m.group(2).strip() or None
103
+ break
104
+ if error_code is None:
105
+ error_code = EXIT_CODE_FALLBACK.get(returncode, "GENERIC")
106
+
107
+ return InferenceResult(
108
+ success=False,
109
+ output_path=None,
110
+ error_code=error_code,
111
+ error_message=error_message,
112
+ )
113
+
114
+
115
+ def resolve_files(folder: Path, recursive: bool) -> list[Path]:
116
+ candidates = folder.rglob("*") if recursive else folder.glob("*")
117
+ return sorted(
118
+ p for p in candidates if p.is_file() and p.suffix.lower() in LOSSY_EXTENSIONS
119
+ )
120
+
121
+
122
+ def output_path_for(
123
+ input_path: Path, source_root: Path, output_dir: Path | None
124
+ ) -> Path:
125
+ stem_name = f"{input_path.stem}.enhanced.flac"
126
+ if output_dir is None:
127
+ return input_path.with_name(stem_name)
128
+ rel_parent = input_path.parent.relative_to(source_root)
129
+ dest_dir = output_dir / rel_parent
130
+ dest_dir.mkdir(parents=True, exist_ok=True)
131
+ return dest_dir / stem_name
132
+
133
+
134
+ def should_skip(output_path: Path) -> bool:
135
+ return output_path.exists()
136
+
137
+
138
+ class BatchSummary(NamedTuple):
139
+ succeeded: list[Path]
140
+ skipped: list[Path]
141
+ failed: list[tuple[Path, str, str]] # (input_path, error_code, message)
142
+
143
+
144
+ def run_batch(
145
+ files: list[Path],
146
+ source_root: Path,
147
+ model_path: Path,
148
+ config_path: Path,
149
+ inference_script: Path,
150
+ output_dir: Path | None,
151
+ skip_existing: bool,
152
+ on_file_progress: Callable[[Path, int, int, int], None] = lambda *a: None,
153
+ ) -> BatchSummary:
154
+ succeeded: list[Path] = []
155
+ skipped: list[Path] = []
156
+ failed: list[tuple[Path, str, str]] = []
157
+
158
+ for i, input_path in enumerate(files, start=1):
159
+ out_path = output_path_for(input_path, source_root, output_dir)
160
+ if skip_existing and should_skip(out_path):
161
+ skipped.append(input_path)
162
+ continue
163
+
164
+ result = run_single_file(
165
+ inference_script,
166
+ model_path,
167
+ config_path,
168
+ input_path,
169
+ out_path,
170
+ on_progress=lambda pct, idx=i, total=len(
171
+ files
172
+ ), p=input_path: on_file_progress(p, idx, total, pct),
173
+ )
174
+ if result.success:
175
+ succeeded.append(input_path)
176
+ else:
177
+ failed.append(
178
+ (input_path, result.error_code or "GENERIC", result.error_message or "")
179
+ )
180
+
181
+ return BatchSummary(succeeded=succeeded, skipped=skipped, failed=failed)
@@ -0,0 +1,181 @@
1
+ """argparse CLI surface: --folder/--file-name/--setup/--check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from tqdm import tqdm
10
+
11
+ from . import batch, model_manager
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(prog="echovault-flac-enhancer")
16
+ mode = parser.add_mutually_exclusive_group(required=True)
17
+ mode.add_argument(
18
+ "--folder", type=Path, help="Enhance all lossy tracks in a folder"
19
+ )
20
+ mode.add_argument("--file-name", type=Path, help="Enhance a single track")
21
+ mode.add_argument(
22
+ "--setup",
23
+ action="store_true",
24
+ help="Download/verify the model and run a self-test",
25
+ )
26
+ mode.add_argument(
27
+ "--check",
28
+ action="store_true",
29
+ help="Report runtime/deps/model/self-test status",
30
+ )
31
+
32
+ parser.add_argument(
33
+ "--recursive", action="store_true", help="Walk subfolders (only with --folder)"
34
+ )
35
+ parser.add_argument(
36
+ "--workers", type=int, default=1, help="Parallel workers (v1: must be 1)"
37
+ )
38
+ parser.add_argument(
39
+ "--skip-existing",
40
+ action="store_true",
41
+ help="Skip files with an existing enhanced output",
42
+ )
43
+ parser.add_argument(
44
+ "--output-dir",
45
+ type=Path,
46
+ default=None,
47
+ help="Write outputs here instead of alongside sources",
48
+ )
49
+ return parser
50
+
51
+
52
+ def _cmd_setup() -> int:
53
+ def on_progress(name: str, done: int, total: int) -> None:
54
+ pct = round(done / total * 100) if total else 0
55
+ print(f"Downloading {name}: {pct}%", end="\r")
56
+
57
+ print("Downloading model assets...")
58
+ paths = model_manager.ensure_model_assets(on_progress)
59
+ print("\nRunning self-test...")
60
+ inference_script = model_manager.inference_script_path()
61
+ if model_manager.run_self_test(
62
+ inference_script, paths.model_path, paths.config_path
63
+ ):
64
+ print("SELF-TEST PASS")
65
+ return 0
66
+ print("SELF-TEST FAILED", file=sys.stderr)
67
+ return 1
68
+
69
+
70
+ def _cmd_check() -> int:
71
+ status = model_manager.check_status()
72
+ print("Dependencies:")
73
+ for name, version in status["deps"].items():
74
+ print(f" {name}: {version if version else 'MISSING'}")
75
+ print(
76
+ f"Model: ok={status['model']['ok']} ({status['model']['reason']}) {status['model']['path']}"
77
+ )
78
+ print(
79
+ f"Config: ok={status['config']['ok']} ({status['config']['reason']}) {status['config']['path']}"
80
+ )
81
+ return (
82
+ 0
83
+ if status["model"]["ok"]
84
+ and status["config"]["ok"]
85
+ and all(status["deps"].values())
86
+ else 1
87
+ )
88
+
89
+
90
+ def _print_summary(summary: batch.BatchSummary) -> None:
91
+ print(
92
+ f"\nSucceeded: {len(summary.succeeded)} Skipped: {len(summary.skipped)} Failed: {len(summary.failed)}"
93
+ )
94
+ for path, code, message in summary.failed:
95
+ print(f" FAILED {path}: {code} {message}")
96
+
97
+
98
+ def _cmd_file(args: argparse.Namespace) -> int:
99
+ paths = model_manager.ensure_model_assets()
100
+ inference_script = model_manager.inference_script_path()
101
+ input_path: Path = args.file_name
102
+ output_path = batch.output_path_for(input_path, input_path.parent, args.output_dir)
103
+
104
+ with tqdm(total=100, desc=input_path.name) as bar:
105
+
106
+ def on_progress(pct: int) -> None:
107
+ bar.n = pct
108
+ bar.refresh()
109
+
110
+ result = batch.run_single_file(
111
+ inference_script,
112
+ paths.model_path,
113
+ paths.config_path,
114
+ input_path,
115
+ output_path,
116
+ on_progress,
117
+ )
118
+
119
+ if result.success:
120
+ print(f"DONE {result.output_path}")
121
+ return 0
122
+ print(f"ERROR {result.error_code} {result.error_message}", file=sys.stderr)
123
+ return {"MODEL_NOT_FOUND": 3, "INPUT_READ_FAILED": 2, "ORT_INIT_FAILED": 4}.get(
124
+ result.error_code, 1
125
+ )
126
+
127
+
128
+ def _cmd_folder(args: argparse.Namespace) -> int:
129
+ if args.workers > 1:
130
+ print(
131
+ "ERROR: parallel workers not implemented yet — v2. Use --workers 1.",
132
+ file=sys.stderr,
133
+ )
134
+ return 1
135
+
136
+ folder: Path = args.folder
137
+ files = batch.resolve_files(folder, args.recursive)
138
+ if not files:
139
+ print("No lossy tracks found.")
140
+ return 0
141
+
142
+ paths = model_manager.ensure_model_assets()
143
+ inference_script = model_manager.inference_script_path()
144
+
145
+ outer = tqdm(total=len(files), position=0, desc="Batch")
146
+ inner = tqdm(total=100, position=1, leave=False)
147
+
148
+ def on_file_progress(path: Path, idx: int, total: int, pct: int) -> None:
149
+ outer.set_postfix_str(f"Track {idx}/{total}")
150
+ inner.n = pct
151
+ inner.refresh()
152
+
153
+ summary = batch.run_batch(
154
+ files,
155
+ folder,
156
+ paths.model_path,
157
+ paths.config_path,
158
+ inference_script,
159
+ args.output_dir,
160
+ args.skip_existing,
161
+ on_file_progress=on_file_progress,
162
+ )
163
+ for _ in files:
164
+ outer.update(1)
165
+ inner.reset()
166
+ outer.close()
167
+ inner.close()
168
+
169
+ _print_summary(summary)
170
+ return 0 if not summary.failed else 1
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> int:
174
+ args = build_parser().parse_args(argv)
175
+ if args.setup:
176
+ return _cmd_setup()
177
+ if args.check:
178
+ return _cmd_check()
179
+ if args.file_name:
180
+ return _cmd_file(args)
181
+ return _cmd_folder(args)
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "baseUrl": "https://github.com/EchoVaultHQ/EchoVault-FLAC-Enhancer/releases/download/v0.1.0",
4
+ "model": {
5
+ "file": "model.onnx",
6
+ "sha256": "b08db07825ebb164a927e9ef50dc5e566d1c13c0515af039fc2350b7a523c379",
7
+ "bytes": 112608277
8
+ },
9
+ "config": {
10
+ "file": "config.json",
11
+ "sha256": "5368dceb6297a20fb1ea3768ffc132585776a2edc173c0e164d8ce1bc871425a",
12
+ "bytes": 280
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ onnxruntime==1.20.1
2
+ soundfile>=0.13.1
3
+ numpy>=1.26
4
+ scipy>=1.11
5
+ mutagen>=1.47