diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1cb5ef..339ae96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,38 @@ on: - Main jobs: - # ── 1. Run test suite ────────────────────────────────────────────────────── + # ── 1. Lint ────────────────────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python 3.13 + run: uv python install 3.13 + + - name: Install dependencies (incl. dev) + run: uv sync --all-groups + + - name: Ruff — lint + run: uv run ruff check . + + - name: Ruff — format check + run: uv run ruff format --check . + + # ── 2. Run test suite ────────────────────────────────────────────────────── test: name: Run Tests + needs: lint runs-on: macos-latest permissions: checks: write @@ -41,11 +70,28 @@ jobs: reporter: java-junit fail-on-error: false - # ── 2. Build macOS app (only when tests pass) ────────────────────────────── + # ── 2. Build per platform (only when tests pass) ────────────────────────── build: - name: Build macOS App + name: Build — ${{ matrix.os-label }} needs: test - runs-on: macos-latest + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + os-label: macOS ARM (Apple Silicon) + artifact: MultiChannelWavMixer-macOS-arm64.zip + + - runner: macos-15-intel + os-label: macOS x64 (Intel) + artifact: MultiChannelWavMixer-macOS-x64.zip + + - runner: windows-latest + os-label: Windows x64 + artifact: MultiChannelWavMixer-Windows-x64.zip steps: - name: Checkout code @@ -62,17 +108,46 @@ jobs: - name: Install dependencies (incl. dev) run: uv sync --all-groups - - name: Build app with PyInstaller - run: bash build.sh + - name: Build with PyInstaller (macOS) + if: runner.os == 'macOS' + run: | + uv run pyinstaller MultiChannelWavMixer.spec --noconfirm --log-level WARN + cd dist && zip -r ${{ matrix.artifact }} MultiChannelWavMixer.app + + - name: Build with PyInstaller (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + uv run pyinstaller MultiChannelWavMixer.spec --noconfirm --log-level WARN + Compress-Archive -Path dist\MultiChannelWavMixer -DestinationPath dist\${{ matrix.artifact }} - - name: Zip app bundle + - name: Smoke test — macOS + if: runner.os == 'macOS' + run: | + dist/MultiChannelWavMixer.app/Contents/MacOS/MultiChannelWavMixer & + APP_PID=$! + sleep 10 + if kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" + echo "Smoke test passed: app still running after 10s" + else + wait "$APP_PID" + EXIT=$? + [ $EXIT -eq 0 ] || (echo "App crashed with exit $EXIT"; exit 1) + fi + + - name: Smoke test — Windows + if: runner.os == 'Windows' + shell: pwsh run: | - cd dist - zip -r MultiChannelWavMixer.zip MultiChannelWavMixer.app + $proc = Start-Process -FilePath 'dist\MultiChannelWavMixer\MultiChannelWavMixer.exe' -PassThru + Start-Sleep 10 + if (!$proc.HasExited) { $proc.Kill(); exit 0 } + if ($proc.ExitCode -ne 0) { Write-Error "App crashed with exit $($proc.ExitCode)"; exit 1 } - name: Upload build artifact uses: actions/upload-artifact@v4 with: - name: MultiChannelWavMixer-macOS - path: dist/MultiChannelWavMixer.zip + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e1f22b..d8d9698 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,9 +50,16 @@ jobs: id: cz_manual if: github.event_name == 'workflow_dispatch' run: | - uv run cz bump --yes --exact-version ${{ inputs.version }} - echo "bumped=true" >> "$GITHUB_OUTPUT" - echo "new_version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + VERSION="${{ inputs.version }}" + # Update version in pyproject.toml if it differs + sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml + if ! git diff --quiet pyproject.toml; then + git add pyproject.toml + git commit -m "bump: release v${VERSION}" + fi + git tag -a "v${VERSION}" -m "Release v${VERSION}" + echo "bumped=true" >> "$GITHUB_OUTPUT" + echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT" # ── Auto: derive version from conventional commits ──────────────────── # Exit code 21 = "nothing to release" — treated as success (no-op). @@ -102,13 +109,17 @@ jobs: fail-fast: false matrix: include: - - runner: macos-latest - os-label: macOS - artifact: MultiChannelWavMixer-macOS.zip + - runner: macos-14 + os-label: macOS ARM (Apple Silicon) + artifact: MultiChannelWavMixer-macOS-arm64.zip + + - runner: macos-15-intel + os-label: macOS x64 (Intel) + artifact: MultiChannelWavMixer-macOS-x64.zip - - runner: windows-latest - os-label: Windows - artifact: MultiChannelWavMixer-Windows.zip + - runner: windows-latest + os-label: Windows x64 + artifact: MultiChannelWavMixer-Windows-x64.zip steps: - name: Checkout code at new tag @@ -140,6 +151,30 @@ jobs: uv run pyinstaller MultiChannelWavMixer.spec --noconfirm --log-level WARN Compress-Archive -Path dist\MultiChannelWavMixer -DestinationPath dist\${{ matrix.artifact }} + - name: Smoke test — macOS + if: runner.os == 'macOS' + run: | + dist/MultiChannelWavMixer.app/Contents/MacOS/MultiChannelWavMixer & + APP_PID=$! + sleep 10 + if kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" + echo "Smoke test passed: app still running after 10s" + else + wait "$APP_PID" + EXIT=$? + [ $EXIT -eq 0 ] || (echo "App crashed with exit $EXIT"; exit 1) + fi + + - name: Smoke test — Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $proc = Start-Process -FilePath 'dist\MultiChannelWavMixer\MultiChannelWavMixer.exe' -PassThru + Start-Sleep 10 + if (!$proc.HasExited) { $proc.Kill(); exit 0 } + if ($proc.ExitCode -ne 0) { Write-Error "App crashed with exit $($proc.ExitCode)"; exit 1 } + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/MultiChannelWavMixer.py b/MultiChannelWavMixer.py index 20e788e..f848eb5 100644 --- a/MultiChannelWavMixer.py +++ b/MultiChannelWavMixer.py @@ -1,31 +1,32 @@ -import customtkinter as ctk -import tkinter as tk -from tkinter import filedialog, messagebox -import soundfile as sf -import numpy as np import os import sys import tempfile +import tkinter as tk from datetime import datetime +from tkinter import filedialog, messagebox + +import customtkinter as ctk +import librosa import matplotlib.pyplot as plt -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import numpy as np import pyloudnorm as pyln -import librosa +import soundfile as sf +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from pydub import AudioSegment from mixer_utils import ( - clean_xml, - parse_tracks_from_ixml, - load_raw_config, - save_raw_config, + build_mix_preview, build_stereo_mix, - process_audio, + build_track_preview, + clean_xml, + db_to_linear, extract_bpm, + load_raw_config, + parse_tracks_from_ixml, play_audio, + process_audio, + save_raw_config, stop_playback, - build_track_preview, - build_mix_preview, - db_to_linear, ) # ─── Appearance ──────────────────────────────────────────────────────────────── @@ -36,9 +37,7 @@ # Resolve paths that must work both from source and inside a .app bundle. if getattr(sys, "frozen", False): # Running inside PyInstaller bundle — store user data in Application Support - _APP_DATA_DIR = os.path.expanduser( - "~/Library/Application Support/MultiChannelWavMixer" - ) + _APP_DATA_DIR = os.path.expanduser("~/Library/Application Support/MultiChannelWavMixer") os.makedirs(_APP_DATA_DIR, exist_ok=True) else: # Running from source — keep files next to the script @@ -46,6 +45,7 @@ CONFIG_FILE = os.path.join(_APP_DATA_DIR, "MixConf.json") + def load_mix_config(): """Load MixConf.json and return a dict of channel data wrapped in tkinter variables.""" raw = load_raw_config(CONFIG_FILE) @@ -56,7 +56,7 @@ def load_mix_config(): # and convert to dB so the new slider can display it correctly. if 0.0 < raw_vol <= 2.0 and raw_vol != round(raw_vol): vol_db = float(np.clip(20.0 * np.log10(max(raw_vol, 1e-6)), -60.0, 6.0)) - elif raw_vol in (0.5, 1.0, 1.5, 2.0): # common linear round numbers + elif raw_vol in (0.5, 1.0, 1.5, 2.0): # common linear round numbers vol_db = float(np.clip(20.0 * np.log10(max(raw_vol, 1e-6)), -60.0, 6.0)) else: vol_db = float(np.clip(raw_vol, -60.0, 6.0)) @@ -68,19 +68,29 @@ def load_mix_config(): } return mix_config + def save_mix_config(mix_config): """Unwrap tkinter variables and persist channel config to MixConf.json.""" raw_config = { name: { - "index": values["index"].get() if isinstance(values["index"], tk.IntVar) else values["index"], - "volume": values["volume"].get() if isinstance(values["volume"], tk.DoubleVar) else values["volume"], - "pan": values["pan"].get() if isinstance(values["pan"], tk.DoubleVar) else values["pan"], - "use_for_mixdown": values["use_for_mixdown"].get() if isinstance(values["use_for_mixdown"], tk.BooleanVar) else values["use_for_mixdown"], + "index": values["index"].get() + if isinstance(values["index"], tk.IntVar) + else values["index"], + "volume": values["volume"].get() + if isinstance(values["volume"], tk.DoubleVar) + else values["volume"], + "pan": values["pan"].get() + if isinstance(values["pan"], tk.DoubleVar) + else values["pan"], + "use_for_mixdown": values["use_for_mixdown"].get() + if isinstance(values["use_for_mixdown"], tk.BooleanVar) + else values["use_for_mixdown"], } for name, values in mix_config.items() } save_raw_config(raw_config, CONFIG_FILE) + def parse_ixml(file_path: str) -> list: """Read iXML metadata from *file_path* and return tracks wrapped in tkinter variables.""" with open(file_path, "rb") as f: @@ -96,15 +106,17 @@ def parse_ixml(file_path: str) -> list: { "index": tk.IntVar(value=t["index"]), "name": t["name"], - "volume": tk.DoubleVar(value=0.0), # 0.0 dB = unity gain + "volume": tk.DoubleVar(value=0.0), # 0.0 dB = unity gain "pan": tk.DoubleVar(value=t["pan"]), "use_for_mixdown": tk.BooleanVar(value=t["use_for_mixdown"]), } for t in plain_tracks ] + # ─── UI callback functions ───────────────────────────────────────────────────── + def load_wav(): """Open one or more WAV files, parse iXML metadata and populate the track list.""" global file_paths @@ -140,35 +152,34 @@ def _rebuild_track_rows(): # ── Header row ────────────────────────────────────────────────────────── HEADER_FONT = ctk.CTkFont(size=12, weight="bold") - headers = ["#", "Mix", "Track Name", "Volume", "", "Pan", "", "Play"] - col_widths = [ 40, 40, 185, 120, 62, 120, 38, 42 ] - for col, (text, w) in enumerate(zip(headers, col_widths)): + headers = ["#", "Mix", "Track Name", "Volume", "", "Pan", "", "Play"] + col_widths = [40, 40, 185, 120, 62, 120, 38, 42] + for col, (text, w) in enumerate(zip(headers, col_widths, strict=True)): ctk.CTkLabel( - frame_controls, text=text, width=w, - font=HEADER_FONT, text_color=("gray60", "gray50") + frame_controls, text=text, width=w, font=HEADER_FONT, text_color=("gray60", "gray50") ).grid(row=0, column=col, padx=(4, 2), pady=(6, 4), sticky="w") # ── Track rows ─────────────────────────────────────────────────────────── for i, track in enumerate(tracks, start=1): - row_bg = ("gray90", "gray17") if i % 2 == 0 else ("gray95", "gray20") - # Index entry ctk.CTkEntry( - frame_controls, width=40, height=28, - textvariable=track["index"], justify="center" + frame_controls, width=40, height=28, textvariable=track["index"], justify="center" ).grid(row=i, column=0, padx=(4, 2), pady=3, sticky="w") # Mixdown checkbox ctk.CTkCheckBox( - frame_controls, text="", width=28, height=28, + frame_controls, + text="", + width=28, + height=28, variable=track["use_for_mixdown"], - onvalue=True, offvalue=False + onvalue=True, + offvalue=False, ).grid(row=i, column=1, padx=(2, 2), pady=3) # Track name ctk.CTkLabel( - frame_controls, text=track["name"], width=185, - anchor="w", font=ctk.CTkFont(size=12) + frame_controls, text=track["name"], width=185, anchor="w", font=ctk.CTkFont(size=12) ).grid(row=i, column=2, padx=(4, 8), pady=3, sticky="w") # Volume slider (dB) + live value label @@ -178,22 +189,21 @@ def _fmt_db(v: float) -> str: return "-\u221e dB" if v <= -59.5 else f"{v:+.1f} dB" vol_str = tk.StringVar(value=_fmt_db(vol_var.get())) - vol_var.trace_add( - "write", - lambda *_, v=vol_var, s=vol_str: s.set(_fmt_db(v.get())) - ) + vol_var.trace_add("write", lambda *_, v=vol_var, s=vol_str: s.set(_fmt_db(v.get()))) vol_slider = ctk.CTkSlider( - frame_controls, from_=-60, to=6, width=120, - variable=vol_var, number_of_steps=264 + frame_controls, from_=-60, to=6, width=120, variable=vol_var, number_of_steps=264 ) vol_slider.grid(row=i, column=3, padx=(2, 2), pady=3) - vol_slider.bind("", - lambda e, v=vol_var: v.set(0.0)) + vol_slider.bind("", lambda e, v=vol_var: v.set(0.0)) ctk.CTkLabel( - frame_controls, textvariable=vol_str, width=62, - font=ctk.CTkFont(size=11), text_color=("gray40", "gray70"), anchor="w" + frame_controls, + textvariable=vol_str, + width=62, + font=ctk.CTkFont(size=11), + text_color=("gray40", "gray70"), + anchor="w", ).grid(row=i, column=4, padx=(0, 6), pady=3, sticky="w") # Pan slider + live value label @@ -202,21 +212,26 @@ def _fmt_db(v: float) -> str: pan_var.trace_add("write", lambda *_, v=pan_var, s=pan_str: s.set(f"{v.get():.2f}")) pan_slider = ctk.CTkSlider( - frame_controls, from_=0, to=1, width=120, - variable=pan_var, number_of_steps=100 + frame_controls, from_=0, to=1, width=120, variable=pan_var, number_of_steps=100 ) pan_slider.grid(row=i, column=5, padx=(2, 2), pady=3) - pan_slider.bind("", - lambda e, v=pan_var: v.set(0.5)) + pan_slider.bind("", lambda e, v=pan_var: v.set(0.5)) ctk.CTkLabel( - frame_controls, textvariable=pan_str, width=38, - font=ctk.CTkFont(size=11), text_color=("gray40", "gray70"), anchor="w" + frame_controls, + textvariable=pan_str, + width=38, + font=ctk.CTkFont(size=11), + text_color=("gray40", "gray70"), + anchor="w", ).grid(row=i, column=6, padx=(0, 4), pady=3, sticky="w") # Per-track play button play_btn = ctk.CTkButton( - frame_controls, text="\u25b6", width=34, height=28, + frame_controls, + text="\u25b6", + width=34, + height=28, font=ctk.CTkFont(size=11), fg_color=("gray70", "gray30"), hover_color=("gray55", "gray45"), @@ -252,6 +267,7 @@ def _reset_btn_if_current(btn) -> None: pass _active_play_btn = None + def _toggle_track_play(track: dict, btn) -> None: """Play / stop a single track channel.""" global _active_play_btn, _wav_data, _wav_samplerate @@ -269,8 +285,11 @@ def _toggle_track_play(track: dict, btn) -> None: preview = build_track_preview(_wav_data, idx) _active_play_btn = btn btn.configure(text="\u25a0") - play_audio(preview, _wav_samplerate, - on_finished=lambda b=btn: root.after(0, lambda: _reset_btn_if_current(b))) + play_audio( + preview, + _wav_samplerate, + on_finished=lambda b=btn: root.after(0, lambda: _reset_btn_if_current(b)), + ) def preview_mix() -> None: @@ -291,7 +310,8 @@ def preview_mix() -> None: "volume": db_to_linear(t["volume"].get()), "pan": t["pan"].get(), } - for t in tracks if t["use_for_mixdown"].get() + for t in tracks + if t["use_for_mixdown"].get() ] if not active_tracks: messagebox.showwarning("Preview Mix", "No tracks selected for mixdown.") @@ -299,8 +319,7 @@ def preview_mix() -> None: preview = build_mix_preview(_wav_data, active_tracks, _wav_samplerate, loudness_option.get()) _active_play_btn = btn_listen_mix btn_listen_mix.configure(text="\u25a0 Stop") - play_audio(preview, _wav_samplerate, - on_finished=lambda: root.after(0, _reset_listen_mix_btn)) + play_audio(preview, _wav_samplerate, on_finished=lambda: root.after(0, _reset_listen_mix_btn)) def _reset_listen_mix_btn() -> None: @@ -318,10 +337,16 @@ def update_mix_config(): mix_config = load_mix_config() for track in tracks: mix_config[track["name"]] = { - "index": track["index"].get() if isinstance(track["index"], tk.IntVar) else track["index"], - "volume": track["volume"].get() if isinstance(track["volume"], tk.DoubleVar) else track["volume"], + "index": track["index"].get() + if isinstance(track["index"], tk.IntVar) + else track["index"], + "volume": track["volume"].get() + if isinstance(track["volume"], tk.DoubleVar) + else track["volume"], "pan": track["pan"].get() if isinstance(track["pan"], tk.DoubleVar) else track["pan"], - "use_for_mixdown": track["use_for_mixdown"].get() if isinstance(track["use_for_mixdown"], tk.BooleanVar) else track["use_for_mixdown"], + "use_for_mixdown": track["use_for_mixdown"].get() + if isinstance(track["use_for_mixdown"], tk.BooleanVar) + else track["use_for_mixdown"], } save_mix_config(mix_config) @@ -346,15 +371,15 @@ def mix_to_stereo(): progress_lbl = ctk.CTkLabel(dlg, text="0.0 % — calculating …", font=ctk.CTkFont(size=11)) progress_lbl.pack(pady=4) - file_sizes = [os.path.getsize(f) / (1024 ** 3) for f in file_paths] - estimated_total_time = sum(file_sizes) * 15 # ~15 s per GB + file_sizes = [os.path.getsize(f) / (1024**3) for f in file_paths] + estimated_total_time = sum(file_sizes) * 15 # ~15 s per GB start_time = datetime.now() dlg.update() for i, ifname in enumerate(file_paths): print(f"Processing: {ifname}") start_time_file = datetime.now() - filesize = os.path.getsize(ifname) / (1024 ** 3) + filesize = os.path.getsize(ifname) / (1024**3) path, Outfilename = os.path.split(ifname) Outfilename, _ = os.path.splitext(Outfilename) @@ -396,9 +421,14 @@ def mix_to_stereo(): temp_wav_path = os.path.join(tempfile.gettempdir(), "multichannelmixer_temp.wav") sf.write(temp_wav_path, stereo, samplerate) audio = AudioSegment.from_wav(temp_wav_path) - audio = process_audio(audio, PHASE_DBFS_THRESH=3.25, SAMPLE_WIDTH=2, - NORMALIZATION_HEADROOM=1, APPLY_FADE_LEN_THRESH_S=3, - FADE_DURATION=80) + audio = process_audio( + audio, + PHASE_DBFS_THRESH=3.25, + SAMPLE_WIDTH=2, + NORMALIZATION_HEADROOM=1, + APPLY_FADE_LEN_THRESH_S=3, + FADE_DURATION=80, + ) y, sr = librosa.load(temp_wav_path) tempo = extract_bpm(y, sr) print(f"Estimated tempo: {tempo:.1f} BPM") @@ -408,14 +438,13 @@ def mix_to_stereo(): tempo_str = f"{int(tempo)}BPM" fmt = output_format.get() out_path = os.path.join( - output_folder.get(), - f"{Outfilename}_{loudness_str}_{tempo_str}_{timestamp}.{fmt}" + output_folder.get(), f"{Outfilename}_{loudness_str}_{tempo_str}_{timestamp}.{fmt}" ) audio.export(out_path, format=fmt) print(f"Stored as {fmt.upper()}: {out_path}") t = (datetime.now() - start_time_file).total_seconds() - print(f"{t:.1f} s for {filesize:.3f} GB — {t/max(filesize, 0.001):.1f} s/GB") + print(f"{t:.1f} s for {filesize:.3f} GB — {t / max(filesize, 0.001):.1f} s/GB") else: messagebox.showerror("Error", "No output folder selected!") @@ -424,9 +453,7 @@ def mix_to_stereo(): progress_bar.set(fraction) elapsed = (datetime.now() - start_time).total_seconds() remaining = max(estimated_total_time - elapsed, 0) - progress_lbl.configure( - text=f"{fraction * 100:.1f} % — {remaining:.0f} s remaining" - ) + progress_lbl.configure(text=f"{fraction * 100:.1f} % — {remaining:.0f} s remaining") dlg.update() dlg.destroy() @@ -438,8 +465,6 @@ def mix_to_stereo(): os.system(f'start "" "{output_folder.get()}"') - - def preview_tracks(): """Render a mini waveform for every track and add it as an extra column.""" global file_paths @@ -450,8 +475,11 @@ def preview_tracks(): # Header for the preview column (col 8 — after the play button at col 7) ctk.CTkLabel( - frame_controls, text="Waveform", width=220, - font=ctk.CTkFont(size=12, weight="bold"), text_color=("gray60", "gray50") + frame_controls, + text="Waveform", + width=220, + font=ctk.CTkFont(size=12, weight="bold"), + text_color=("gray60", "gray50"), ).grid(row=0, column=8, padx=(8, 4), pady=(6, 4)) for i, track in enumerate(tracks, start=1): @@ -462,7 +490,7 @@ def preview_tracks(): ax.set_facecolor("#1e1e1e") sample_points = np.linspace(0, len(data[:, idx]) - 1, min(1000, len(data)), dtype=int) ax.plot(data[sample_points, idx] * vol_linear, color="#3b8ed0", linewidth=0.6) - ax.set_ylim(-1.0, 1.0) # fixed axis so tracks are comparable + ax.set_ylim(-1.0, 1.0) # fixed axis so tracks are comparable ax.axis("off") fig_canvas = FigureCanvasTkAgg(fig, master=frame_controls) @@ -476,7 +504,9 @@ def set_output_folder(inFilePath=None): if inFilePath and os.path.exists(inFilePath): folder_selected = inFilePath else: - folder_selected = filedialog.askdirectory(initialdir=output_folder.get() or os.path.expanduser("~")) + folder_selected = filedialog.askdirectory( + initialdir=output_folder.get() or os.path.expanduser("~") + ) if folder_selected: output_folder.set(folder_selected) @@ -491,10 +521,12 @@ def set_output_folder(inFilePath=None): root.geometry("1060x560") root.minsize(900, 420) + def bring_to_front(event=None): root.attributes("-topmost", True) root.after(150, lambda: root.attributes("-topmost", False)) + root.bind("", bring_to_front) # ─── State variables ─────────────────────────────────────────────────────────── @@ -517,22 +549,34 @@ def bring_to_front(event=None): toolbar.pack_propagate(False) btn_load = ctk.CTkButton( - toolbar, text="Load WAV", width=105, height=34, - font=ctk.CTkFont(size=13), command=load_wav + toolbar, text="Load WAV", width=105, height=34, font=ctk.CTkFont(size=13), command=load_wav ) btn_load.pack(side="left", padx=(12, 6), pady=10) btn_preview = ctk.CTkButton( - toolbar, text="Waveforms", width=100, height=34, state="disabled", - font=ctk.CTkFont(size=13), command=preview_tracks, - fg_color=("gray70", "gray30"), hover_color=("gray60", "gray40"), text_color=("gray20", "gray90") + toolbar, + text="Waveforms", + width=100, + height=34, + state="disabled", + font=ctk.CTkFont(size=13), + command=preview_tracks, + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + text_color=("gray20", "gray90"), ) btn_preview.pack(side="left", padx=6, pady=10) btn_out = ctk.CTkButton( - toolbar, text="Output Folder", width=120, height=34, - font=ctk.CTkFont(size=13), command=set_output_folder, - fg_color=("gray70", "gray30"), hover_color=("gray60", "gray40"), text_color=("gray20", "gray90") + toolbar, + text="Output Folder", + width=120, + height=34, + font=ctk.CTkFont(size=13), + command=set_output_folder, + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + text_color=("gray20", "gray90"), ) btn_out.pack(side="left", padx=6, pady=10) @@ -541,42 +585,56 @@ def bring_to_front(event=None): # Loudness option menu ctk.CTkLabel( - toolbar, text="Loudness:", font=ctk.CTkFont(size=12), - text_color=("gray40", "gray65") + toolbar, text="Loudness:", font=ctk.CTkFont(size=12), text_color=("gray40", "gray65") ).pack(side="left", padx=(6, 2), pady=10) loudness_menu = ctk.CTkOptionMenu( - toolbar, variable=loudness_option, width=120, height=34, + toolbar, + variable=loudness_option, + width=120, + height=34, values=["none", "-1dBFS", "-12dB LUFS"], - font=ctk.CTkFont(size=12) + font=ctk.CTkFont(size=12), ) loudness_menu.pack(side="left", padx=(0, 6), pady=10) # Output format segmented button ctk.CTkLabel( - toolbar, text="Format:", font=ctk.CTkFont(size=12), - text_color=("gray40", "gray65") + toolbar, text="Format:", font=ctk.CTkFont(size=12), text_color=("gray40", "gray65") ).pack(side="left", padx=(10, 2), pady=10) fmt_btn = ctk.CTkSegmentedButton( - toolbar, values=["MP3", "WAV"], height=34, + toolbar, + values=["MP3", "WAV"], + height=34, font=ctk.CTkFont(size=12), - command=lambda v: output_format.set(v.lower()) + command=lambda v: output_format.set(v.lower()), ) fmt_btn.set("MP3") fmt_btn.pack(side="left", padx=(0, 10), pady=10) # Mix button (rightmost) btn_mix = ctk.CTkButton( - toolbar, text="Mix to Stereo ▶", width=140, height=34, - font=ctk.CTkFont(size=13, weight="bold"), command=mix_to_stereo, - fg_color="#2d7dd2", hover_color="#1a5fa8" + toolbar, + text="Mix to Stereo ▶", + width=140, + height=34, + font=ctk.CTkFont(size=13, weight="bold"), + command=mix_to_stereo, + fg_color="#2d7dd2", + hover_color="#1a5fa8", ) btn_mix.pack(side="right", padx=(6, 14), pady=10) # Listen Mix button (left of Mix to Stereo) btn_listen_mix = ctk.CTkButton( - toolbar, text="Listen Mix \u25b6", width=130, height=34, state="disabled", - font=ctk.CTkFont(size=13), command=preview_mix, - fg_color="#2d8a4e", hover_color="#1f6438", + toolbar, + text="Listen Mix \u25b6", + width=130, + height=34, + state="disabled", + font=ctk.CTkFont(size=13), + command=preview_mix, + fg_color="#2d8a4e", + hover_color="#1f6438", ) btn_listen_mix.pack(side="right", padx=(0, 6), pady=10) @@ -586,21 +644,24 @@ def bring_to_front(event=None): status_bar.pack_propagate(False) ctk.CTkLabel( - status_bar, text="Output:", font=ctk.CTkFont(size=11), - text_color=("gray40", "gray60"), width=52 + status_bar, text="Output:", font=ctk.CTkFont(size=11), text_color=("gray40", "gray60"), width=52 ).pack(side="left", padx=(10, 2)) lbl_output_folder = ctk.CTkLabel( - status_bar, text="No folder selected", font=ctk.CTkFont(size=11), - text_color=("gray30", "gray70"), anchor="w" + status_bar, + text="No folder selected", + font=ctk.CTkFont(size=11), + text_color=("gray30", "gray70"), + anchor="w", ) lbl_output_folder.pack(side="left", fill="x", expand=True) # ─── Scrollable track area ───────────────────────────────────────────────────── frame_controls = ctk.CTkScrollableFrame( - root, corner_radius=0, + root, + corner_radius=0, fg_color=("gray97", "gray13"), scrollbar_button_color=("gray70", "gray35"), - scrollbar_button_hover_color=("gray55", "gray50") + scrollbar_button_hover_color=("gray55", "gray50"), ) frame_controls.pack(fill="both", expand=True) diff --git a/MultiChannelWavMixer.spec b/MultiChannelWavMixer.spec index de5c5e6..fea04d6 100644 --- a/MultiChannelWavMixer.spec +++ b/MultiChannelWavMixer.spec @@ -65,8 +65,6 @@ hiddenimports = [ "tkinter.messagebox", # misc "xml.etree.ElementTree", - "numba", - "llvmlite", ] # collect all submodules of scipy and librosa to avoid missing-module errors @@ -86,6 +84,7 @@ a = Analysis( excludes=[ "PyQt5", "PyQt6", "PySide2", "PySide6", "wx", "gi", + "numba", "llvmlite", ], win_no_prefer_redirects=False, win_private_assemblies=False, diff --git a/mixer_utils.py b/mixer_utils.py index 932f618..ad93354 100644 --- a/mixer_utils.py +++ b/mixer_utils.py @@ -13,7 +13,8 @@ import threading import time import xml.etree.ElementTree as ET -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import librosa import numpy as np @@ -22,6 +23,7 @@ # ── XML / iXML helpers ────────────────────────────────────────────────────────── + def clean_xml(data: str) -> str: """Strip everything before `` list[dict[str, Any]]: pan = 1.0 else: pan = 0.5 - result.append({ - "index": int(index_str), - "name": name, - "volume": 1.0, - "pan": pan, - "use_for_mixdown": True, - }) + result.append( + { + "index": int(index_str), + "name": name, + "volume": 1.0, + "pan": pan, + "use_for_mixdown": True, + } + ) return result # ── Config I/O (plain dicts – no tkinter) ─────────────────────────────────────── + def load_raw_config(path: str = "MixConf.json") -> dict[str, dict[str, Any]]: """Load *path* and return the channel config as plain Python dicts.""" if not os.path.exists(path): return {} - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: try: return json.load(f) except json.JSONDecodeError: @@ -96,6 +101,7 @@ def save_raw_config(config: dict[str, dict[str, Any]], path: str = "MixConf.json # ── Stereo mixing ──────────────────────────────────────────────────────────────── + def build_stereo_mix( data: np.ndarray, active_tracks: list[dict[str, Any]], @@ -120,6 +126,7 @@ def build_stereo_mix( # ── Audio post-processing ──────────────────────────────────────────────────────── + def process_audio( wav_in: AudioSegment, PHASE_DBFS_THRESH: float, @@ -142,9 +149,7 @@ def process_audio( if has_phase_issues: split = wav_in.split_to_mono() - stereo_sound = AudioSegment.from_mono_audiosegments( - split[0], split[1].invert_phase() - ) + stereo_sound = AudioSegment.from_mono_audiosegments(split[0], split[1].invert_phase()) else: stereo_sound = wav_in.set_channels(2) @@ -162,6 +167,7 @@ def process_audio( # ── BPM detection ──────────────────────────────────────────────────────────────── + def extract_bpm(y: np.ndarray, sr: int) -> float: """Return the estimated tempo in BPM using librosa's beat tracker.""" onset_env = librosa.onset.onset_strength(y=y, sr=sr) @@ -181,11 +187,11 @@ def extract_bpm(y: np.ndarray, sr: int) -> float: # 4. A generation counter ensures only the *latest* click's background thread # opens a stream; superseded threads exit silently. -_playback_event: threading.Event = threading.Event() # set ↔ stream is alive -_current_stop: threading.Event | None = None # signal current stream -_playback_generation: int = 0 # incremented on each launch -_playback_lock: threading.Lock = threading.Lock() # guards above globals -_active_stream: object | None = None # keeps OutputStream alive +_playback_event: threading.Event = threading.Event() # set ↔ stream is alive +_current_stop: threading.Event | None = None # signal current stream +_playback_generation: int = 0 # incremented on each launch +_playback_lock: threading.Lock = threading.Lock() # guards above globals +_active_stream: object | None = None # keeps OutputStream alive def db_to_linear(db: float, floor_db: float = -60.0) -> float: @@ -259,7 +265,7 @@ def _callback(outdata: np.ndarray, frames: int, time_info, status) -> None: # n def _finished() -> None: global _active_stream - _active_stream = None # release the stream ref *after* PA is done + _active_stream = None # release the stream ref *after* PA is done _playback_event.clear() if on_finished: on_finished() @@ -272,7 +278,7 @@ def _finished() -> None: finished_callback=_finished, dtype="float32", ) - _active_stream = stream # prevent GC while callback is alive + _active_stream = stream # prevent GC while callback is alive stream.start() threading.Thread(target=_launch, daemon=True).start() diff --git a/pyi_rth_env.py b/pyi_rth_env.py index 1531513..a9135ab 100644 --- a/pyi_rth_env.py +++ b/pyi_rth_env.py @@ -3,13 +3,13 @@ Adds common macOS binary paths (Homebrew, MacPorts, /usr/local) to PATH so that pydub can locate ffmpeg/ffprobe inside the .app bundle. """ + import os -import sys _extra_paths = [ - "/opt/homebrew/bin", # Apple Silicon Homebrew - "/usr/local/bin", # Intel Homebrew / manual installs - "/opt/local/bin", # MacPorts + "/opt/homebrew/bin", # Apple Silicon Homebrew + "/usr/local/bin", # Intel Homebrew / manual installs + "/opt/local/bin", # MacPorts "/usr/bin", "/bin", ] diff --git a/pyproject.toml b/pyproject.toml index 2715a0b..01f6a88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "multichannel-wav-mixer" -version = "1.0.0" +version = "1.0.1" description = "Multichannel WAV file mixer with stereo mixdown" readme = "README.md" license = { file = "LICENSE" } @@ -19,6 +19,11 @@ dependencies = [ [tool.uv] package = false +override-dependencies = [ + # llvmlite (numba dep) has no pre-built macOS x86_64 wheel for Python 3.13+; + # librosa works without numba — it is a JIT accelerator with pure-Python fallbacks. + "numba ; platform_machine == 'arm64' or sys_platform != 'darwin'", +] [dependency-groups] dev = [ @@ -26,15 +31,36 @@ dev = [ "pyinstaller>=6.19.0", "pytest>=8.0", "pytest-cov>=5.0", + "ruff>=0.9", ] [tool.commitizen] name = "cz_conventional_commits" -version = "1.0.0" +version = "1.0.1" version_files = ["pyproject.toml:version"] tag_format = "v$version" update_changelog_on_bump = true +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear +] +ignore = [ + "E501", # line too long — handled by formatter +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] diff --git a/tests/test_mixer_utils.py b/tests/test_mixer_utils.py index 9edd223..bf6f781 100644 --- a/tests/test_mixer_utils.py +++ b/tests/test_mixer_utils.py @@ -8,9 +8,6 @@ from __future__ import annotations import json -import os -import struct -import tempfile import threading import time from unittest.mock import MagicMock, patch @@ -19,6 +16,8 @@ import pytest from pydub import AudioSegment +import mixer_utils as _mixer_utils # for _playback_event access + # ── module under test ───────────────────────────────────────────────────────── from mixer_utils import ( build_mix_preview, @@ -34,12 +33,12 @@ save_raw_config, stop_playback, ) -import mixer_utils as _mixer_utils # for _playback_event access # ╔══════════════════════════════════════════════════════════════════════════════╗ # ║ Helpers ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + def _make_stereo_segment( duration_ms: int = 5_000, freq: float = 440.0, @@ -73,6 +72,7 @@ def _make_multichannel_array(n_samples: int = 44_100, n_channels: int = 4) -> np # ║ clean_xml ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestCleanXml: def test_strips_prefix_before_xml_declaration(self): raw = "JUNK BYTES\x00" @@ -204,7 +204,7 @@ def test_missing_name_element_uses_unknown(self): SAMPLE_CONFIG: dict = { "Guitar L": {"index": 1, "volume": 1.0, "pan": 0.0, "use_for_mixdown": True}, "Guitar R": {"index": 2, "volume": 1.0, "pan": 1.0, "use_for_mixdown": True}, - "Kick": {"index": 3, "volume": 0.8, "pan": 0.5, "use_for_mixdown": False}, + "Kick": {"index": 3, "volume": 0.8, "pan": 0.5, "use_for_mixdown": False}, } @@ -220,7 +220,6 @@ def test_load_returns_empty_dict_when_file_missing(self, tmp_path): assert load_raw_config(path) == {} def test_load_returns_empty_dict_on_invalid_json(self, tmp_path): - path = str(tmp_path / "broken.json") path_obj = tmp_path / "broken.json" path_obj.write_text("{ this is not json }", encoding="utf-8") assert load_raw_config(str(path_obj)) == {} @@ -252,6 +251,7 @@ def test_volume_precision_preserved(self, tmp_path): # ║ build_stereo_mix ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestBuildStereoMix: def test_output_shape_is_samples_by_two(self): data = _make_multichannel_array(n_samples=1000, n_channels=2) @@ -314,6 +314,7 @@ def test_output_dtype_is_float64(self): # ║ process_audio ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestProcessAudio: """Tests use a 5-second 440 Hz stereo sine wave.""" @@ -357,6 +358,7 @@ def test_frame_rate_preserved(self, audio): # ║ extract_bpm ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestExtractBpm: @pytest.fixture() def click_track(self) -> tuple[np.ndarray, int]: @@ -396,6 +398,7 @@ def test_handles_silent_signal(self): # ║ build_track_preview ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestBuildTrackPreview: def test_output_shape_is_stereo(self): data = _make_multichannel_array(n_samples=1000, n_channels=3) @@ -437,14 +440,12 @@ def test_correct_channel_is_extracted(self): # ║ build_mix_preview ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestBuildMixPreview: SR = 44_100 def _tracks(self, n_ch: int) -> list: - return [ - {"index": i + 1, "volume": 1.0, "pan": 0.5} - for i in range(n_ch) - ] + return [{"index": i + 1, "volume": 1.0, "pan": 0.5} for i in range(n_ch)] def test_output_shape_is_stereo(self): data = _make_multichannel_array(n_samples=self.SR, n_channels=2) @@ -487,6 +488,7 @@ def test_empty_tracks_returns_silence(self): # ║ play_audio / stop_playback (sounddevice mocked) ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestPlayback: """sounddevice is mocked so tests run without an audio device. @@ -578,8 +580,7 @@ def test_finished_callback_calls_on_finished(self): with patch("mixer_utils.sd") as mock_sd: mock_sd.OutputStream.return_value = MagicMock() finished = MagicMock() - play_audio(np.zeros((100, 2), dtype=np.float32), 44_100, - on_finished=finished) + play_audio(np.zeros((100, 2), dtype=np.float32), 44_100, on_finished=finished) time.sleep(0.05) _, kwargs = mock_sd.OutputStream.call_args kwargs["finished_callback"]() @@ -603,10 +604,12 @@ def test_on_finished_not_required(self): _, kwargs = mock_sd.OutputStream.call_args kwargs["finished_callback"]() # must not raise + # ╔══════════════════════════════════════════════════════════════════════════════╗ # ║ db_to_linear ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ + class TestDbToLinear: def test_unity_gain(self): assert db_to_linear(0.0) == pytest.approx(1.0, rel=1e-6) diff --git a/uv.lock b/uv.lock index ef0b301..eedaecb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.13" +[manifest] +overrides = [{ name = "numba", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }] + [[package]] name = "altgraph" version = "0.17.5" @@ -546,7 +549,7 @@ wheels = [ [[package]] name = "librosa" -version = "0.11.0" +version = "0.10.2.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "audioread" }, @@ -554,20 +557,18 @@ dependencies = [ { name = "joblib" }, { name = "lazy-loader" }, { name = "msgpack" }, - { name = "numba" }, + { name = "numba", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }, { name = "numpy" }, { name = "pooch" }, { name = "scikit-learn" }, { name = "scipy" }, { name = "soundfile" }, { name = "soxr" }, - { name = "standard-aifc" }, - { name = "standard-sunau" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload-time = "2025-03-11T15:09:54.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/2d/77783a52641a21ff7e2230aa588e4fb4a61422a64673096a36776b7e5bd9/librosa-0.10.2.post1.tar.gz", hash = "sha256:cd99f16717cbcd1e0983e37308d1db46a6f7dfc2e396e5a9e61e6821e44bd2e7", size = 325533, upload-time = "2024-05-14T15:49:40.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8a/2d231b35456506b7c98b3ab9bbf07917b205fed8615d2e59e976ab497fff/librosa-0.10.2.post1-py3-none-any.whl", hash = "sha256:dc882750e8b577a63039f25661b7e39ec4cfbacc99c1cffba666cd664fb0a7a0", size = 260089, upload-time = "2024-05-14T15:49:38.919Z" }, ] [[package]] @@ -734,7 +735,7 @@ wheels = [ [[package]] name = "multichannel-wav-mixer" -version = "1.0.0" +version = "1.0.1" source = { virtual = "." } dependencies = [ { name = "audioop-lts" }, @@ -754,6 +755,7 @@ dev = [ { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -775,6 +777,7 @@ dev = [ { name = "pyinstaller", specifier = ">=6.19.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.9" }, ] [[package]] @@ -1171,6 +1174,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0"