From 140ad5881da35b8cca5703b03637533f6de67467 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Thu, 21 May 2026 18:33:39 -0700 Subject: [PATCH] Stream click tracks and simplify Windows installer --- .github/workflows/build-installers.yml | 27 +++ docs/development.md | 8 +- docs/guide.md | 8 + openbeat/analysis.py | 51 ++++- openbeat/artifacts.py | 51 ++++- .../Modules/OpenBeat/OpenBeatCommon.lua | 12 +- scripts/build_installers.py | 193 +++++++++++++++--- tests/test_analysis.py | 42 +++- tests/test_build_installers.py | 31 +++ 9 files changed, 373 insertions(+), 50 deletions(-) create mode 100644 tests/test_build_installers.py diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index 6a7598a..80c2657 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -32,8 +32,35 @@ permissions: contents: read jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_ref || github.sha }} + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install . + + - name: Compile Python sources + run: python -m compileall openbeat scripts tests + + - name: Run tests + run: python -m unittest discover -s tests -v + build: name: Build ${{ matrix.platform }} installer + needs: test runs-on: ${{ matrix.runner }} strategy: fail-fast: false diff --git a/docs/development.md b/docs/development.md index b128024..a0da115 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,15 +25,19 @@ Because the script points Resolve at this checkout and its `.venv`, it should be ## Build Installers Locally -Install build dependencies, then run: +Install build dependencies, then run the installer build for the current operating system: ```bash python -m pip install . pyinstaller -python scripts/build_installers.py --platform all +python scripts/build_installers.py --platform macos +# or, on Windows: +python scripts/build_installers.py --platform windows ``` Generated installer archives are written to `dist/installers/`. +Installer builds are platform-specific. Build the macOS `.pkg` on macOS and the Windows `.exe` on Windows; PyInstaller does not cross-compile the bundled runtime. + ## Release Workflow GitHub Actions now has two installer-related workflows: diff --git a/docs/guide.md b/docs/guide.md index aba8520..9cc630d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -78,9 +78,17 @@ That is also a current Resolve scripting limitation. Use the generated `.srt` fi ### Where to look for logs +macOS: + - Runtime log: `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/logs/OpenBeat.log` - Analysis cache: `~/Library/Caches/OpenBeat` +Windows: + +- Install folder: `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion` +- Runtime log: `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\logs\OpenBeat.log` +- Analysis cache: `%LOCALAPPDATA%\OpenBeat\Cache` + ## Technical Details ### Source Selection diff --git a/openbeat/analysis.py b/openbeat/analysis.py index 8d496f4..1894a32 100644 --- a/openbeat/analysis.py +++ b/openbeat/analysis.py @@ -12,6 +12,12 @@ import librosa import numpy as np +from openbeat import __version__ + + +ANALYSIS_TARGET_SR = 22050 +CACHE_SCHEMA_VERSION = "2" + @dataclass(slots=True) class BeatAnalysis: @@ -110,7 +116,7 @@ def quantized_beats(tempo_bpm: float, offset_seconds: float, duration_seconds: f return beats -def analyze_audio(audio_path: str, target_sr: int = 22050) -> BeatAnalysis: +def analyze_audio(audio_path: str, target_sr: int = ANALYSIS_TARGET_SR) -> BeatAnalysis: audio_path = str(Path(audio_path).expanduser().resolve()) signal, sr = librosa.load(audio_path, sr=target_sr, mono=True) duration_seconds = float(librosa.get_duration(y=signal, sr=sr)) @@ -135,8 +141,20 @@ def analyze_audio(audio_path: str, target_sr: int = 22050) -> BeatAnalysis: def default_cache_dir() -> Path: home = Path.home() - if sys_platform() == "darwin": + system = sys_platform() + if system == "darwin": return home / "Library" / "Caches" / "OpenBeat" + + if system == "windows": + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + return Path(local_appdata) / "OpenBeat" / "Cache" + return home / "AppData" / "Local" / "OpenBeat" / "Cache" + + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") + if xdg_cache_home: + return Path(xdg_cache_home) / "openbeat" + return home / ".cache" / "openbeat" @@ -144,25 +162,40 @@ def sys_platform() -> str: return platform.system().lower() -def cache_key(audio_path: str) -> str: +def cache_key(audio_path: str, target_sr: int = ANALYSIS_TARGET_SR) -> str: path = Path(audio_path).expanduser().resolve() stat = path.stat() - payload = f"{path}|{stat.st_size}|{stat.st_mtime_ns}".encode("utf-8") + payload = json.dumps( + { + "path": str(path), + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "target_sr": target_sr, + "cache_schema": CACHE_SCHEMA_VERSION, + "openbeat_version": __version__, + "librosa_version": librosa.__version__, + }, + sort_keys=True, + ).encode("utf-8") return hashlib.sha256(payload).hexdigest()[:24] -def cache_path(audio_path: str, cache_dir: Path | None = None) -> Path: +def cache_path(audio_path: str, cache_dir: Path | None = None, target_sr: int = ANALYSIS_TARGET_SR) -> Path: cache_dir = cache_dir or default_cache_dir() cache_dir.mkdir(parents=True, exist_ok=True) - return cache_dir / f"{cache_key(audio_path)}.json" + return cache_dir / f"{cache_key(audio_path, target_sr=target_sr)}.json" -def load_or_analyze(audio_path: str, cache_dir: Path | None = None) -> BeatAnalysis: - target = cache_path(audio_path, cache_dir) +def load_or_analyze( + audio_path: str, + cache_dir: Path | None = None, + target_sr: int = ANALYSIS_TARGET_SR, +) -> BeatAnalysis: + target = cache_path(audio_path, cache_dir, target_sr=target_sr) if target.exists(): return BeatAnalysis(**json.loads(target.read_text())) - analysis = analyze_audio(audio_path) + analysis = analyze_audio(audio_path, target_sr=target_sr) target.write_text(json.dumps(asdict(analysis), indent=2)) return analysis diff --git a/openbeat/artifacts.py b/openbeat/artifacts.py index f171df9..28ed554 100644 --- a/openbeat/artifacts.py +++ b/openbeat/artifacts.py @@ -24,22 +24,59 @@ def render_click_track( duration_seconds: float, sample_rate: int = 48000, stereo: bool = True, + block_size: int = 48000, ) -> str: output = Path(output_path).expanduser().resolve() output.parent.mkdir(parents=True, exist_ok=True) total_samples = max(1, int(sample_rate * max(duration_seconds + 1.0, 1.0))) - waveform = np.zeros(total_samples, dtype=np.float32) click = _click_kernel(sample_rate) + click_length = len(click) + block_size = max(1, int(block_size)) + click_starts: list[int] = [] for beat in beats: - start = max(0, int(round(beat * sample_rate))) - stop = min(total_samples, start + len(click)) - waveform[start:stop] += click[: stop - start] + start = int(round(beat * sample_rate)) + if start < total_samples: + click_starts.append(max(0, start)) + click_starts.sort() + + channels = 2 if stereo else 1 + next_click = 0 + active_clicks: list[int] = [] + + with sf.SoundFile(output, mode="w", samplerate=sample_rate, channels=channels, subtype="PCM_16") as wav: + for block_start in range(0, total_samples, block_size): + block_stop = min(total_samples, block_start + block_size) + block = np.zeros(block_stop - block_start, dtype=np.float32) + + while next_click < len(click_starts) and click_starts[next_click] < block_stop: + active_clicks.append(click_starts[next_click]) + next_click += 1 + + remaining_clicks: list[int] = [] + for click_start in active_clicks: + click_stop = click_start + click_length + if click_stop <= block_start: + continue + + write_start = max(block_start, click_start) + write_stop = min(block_stop, click_stop) + if write_stop > write_start: + block_offset = write_start - block_start + click_offset = write_start - click_start + write_length = write_stop - write_start + block[block_offset : block_offset + write_length] += click[ + click_offset : click_offset + write_length + ] + + remaining_clicks.append(click_start) + + active_clicks = remaining_clicks + block = np.clip(block, -1.0, 1.0) + output_data = np.column_stack((block, block)) if stereo else block + wav.write(output_data) - waveform = np.clip(waveform, -1.0, 1.0) - output_data = np.column_stack((waveform, waveform)) if stereo else waveform - sf.write(output, output_data, sample_rate, subtype="PCM_16") return str(output) diff --git a/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua b/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua index 48d8f2a..9f09f20 100644 --- a/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua +++ b/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua @@ -493,12 +493,20 @@ local function import_subtitles_to_timeline(resolve_app, project, timeline, subt recordFrame = timeline:GetStartFrame(), trackIndex = target_track, } - local appended = project:GetMediaPool():AppendToTimeline({ clip_info }) - local after_count = track_item_count(timeline, "subtitle", target_track) + local appended = nil + local after_count = before_count + local append_ok, append_error = pcall(function() + appended = project:GetMediaPool():AppendToTimeline({ clip_info }) + after_count = track_item_count(timeline, "subtitle", target_track) + end) restore_track_enabled(timeline, "video", video_states) restore_track_enabled(timeline, "subtitle", subtitle_states) + if not append_ok then + return false, "Resolve failed while placing the subtitle file on the timeline: " .. tostring(append_error) + end + if after_count <= before_count then local append_text = appended and "returned a result" or "returned nil" return false, "Resolve imported the subtitle file but did not place it on the timeline (" .. append_text .. ")." diff --git a/scripts/build_installers.py b/scripts/build_installers.py index 114db1f..6edd7b7 100644 --- a/scripts/build_installers.py +++ b/scripts/build_installers.py @@ -27,29 +27,56 @@ def parse_version() -> str: raise ValueError("Unable to find version in pyproject.toml") -def pyinstaller_binary(output_name: str, entry_script: Path, python_bin: str = "python") -> Path: - run( - [ - python_bin, - "-m", - "PyInstaller", - "--clean", - "--noconfirm", - "--onefile", - "--name", - output_name, - str(entry_script), +def pyinstaller_binary( + output_name: str, + entry_script: Path, + python_bin: str = "python", + *, + onefile: bool = True, + windowed: bool = False, +) -> Path: + for stale_output in (DIST_ROOT / output_name, DIST_ROOT / f"{output_name}.exe"): + if stale_output.is_dir(): + shutil.rmtree(stale_output) + elif stale_output.exists(): + stale_output.unlink() + + cmd = [ + python_bin, + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + "--name", + output_name, + ] + cmd.append("--onefile" if onefile else "--onedir") + if windowed: + cmd.append("--windowed") + cmd.append(str(entry_script)) + run(cmd) + + if onefile: + candidates = [DIST_ROOT / output_name, DIST_ROOT / f"{output_name}.exe"] + else: + candidates = [ + DIST_ROOT / output_name / output_name, + DIST_ROOT / output_name / f"{output_name}.exe", ] - ) - candidates = [DIST_ROOT / output_name, DIST_ROOT / f"{output_name}.exe"] built = next((path for path in candidates if path.exists()), None) if built is None: raise FileNotFoundError(f"Expected bundled binary at one of: {candidates}") return built -def build_cli_binary(python_bin: str = "python") -> Path: - return pyinstaller_binary("openbeat", ROOT / "openbeat" / "cli.py", python_bin=python_bin) +def build_cli_binary(platform: str, python_bin: str = "python") -> Path: + return pyinstaller_binary( + "openbeat", + ROOT / "openbeat" / "cli.py", + python_bin=python_bin, + onefile=platform != "windows", + windowed=platform == "windows", + ) def copy_payload(payload_root: Path) -> None: @@ -144,7 +171,10 @@ def create_windows_exe(cli_binary: Path, version: str, python_bin: str = "python copy_payload(payload_dir) payload_bin_dir = payload_dir / "bin" payload_bin_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(cli_binary, payload_bin_dir / "openbeat.exe") + if cli_binary.parent.name == "openbeat": + shutil.copytree(cli_binary.parent, payload_bin_dir / "openbeat", dirs_exist_ok=True) + else: + shutil.copy2(cli_binary, payload_bin_dir / "openbeat.exe") installer_entry = tmp_path / "windows_installer.py" installer_entry.write_text( @@ -152,6 +182,7 @@ def create_windows_exe(cli_binary: Path, version: str, python_bin: str = "python import os import shutil +import subprocess import sys import threading from pathlib import Path @@ -172,7 +203,42 @@ def default_resolve_root() -> Path: return appdata / 'Blackmagic Design' / 'DaVinci Resolve' / 'Support' / 'Fusion' -def install_openbeat(destination: Path) -> None: +def default_log_path() -> Path: + appdata = Path.home() / 'AppData' / 'Roaming' + if 'APPDATA' in os.environ: + appdata = Path(os.environ['APPDATA']) + return appdata / 'Blackmagic Design' / 'DaVinci Resolve' / 'Support' / 'logs' / 'OpenBeat.log' + + +def resolve_process_running() -> bool: + try: + result = subprocess.run( + ['tasklist', '/FI', 'IMAGENAME eq Resolve.exe'], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return False + return 'Resolve.exe' in result.stdout + + +def installed_runtime_exe(module_target: Path) -> Path: + onedir_exe = module_target / 'bin' / 'openbeat' / 'openbeat.exe' + if onedir_exe.exists(): + return onedir_exe + return module_target / 'bin' / 'openbeat.exe' + + +def open_path(path: Path) -> None: + target = path if path.is_dir() else path.parent + if not target.exists(): + target.mkdir(parents=True, exist_ok=True) + os.startfile(str(target)) + + +def install_openbeat(destination: Path) -> dict[str, str]: payload = resolve_payload_root() resolve_root = destination.expanduser() @@ -190,24 +256,33 @@ def install_openbeat(destination: Path) -> None: shutil.copytree(payload / 'Utility' / 'OpenBeat', utility_target) shutil.copytree(payload / 'Modules' / 'OpenBeat', module_target) (module_target / 'bin').mkdir(parents=True, exist_ok=True) - shutil.copy2(payload / 'bin' / 'openbeat.exe', module_target / 'bin' / 'openbeat.exe') + shutil.copytree(payload / 'bin', module_target / 'bin', dirs_exist_ok=True) - exe_path = (module_target / 'bin' / 'openbeat.exe').as_posix() - config_text = f'return {{\\n python_bin = "{exe_path}",\\n}}\\n' + exe_path = installed_runtime_exe(module_target) + config_text = f'return {{\\n python_bin = "{exe_path.as_posix()}",\\n}}\\n' (module_target / 'OpenBeatConfig.local.lua').write_text(config_text, encoding='utf-8') + return { + 'destination': str(resolve_root), + 'utility_target': str(utility_target), + 'module_target': str(module_target), + 'runtime': str(exe_path), + 'log_path': str(default_log_path()), + } class InstallerWizard(tk.Tk): def __init__(self) -> None: super().__init__() self.title('OpenBeat Setup') - self.geometry('620x420') - self.minsize(560, 380) + self.geometry('720x520') + self.minsize(640, 460) self.resizable(False, False) self.protocol('WM_DELETE_WINDOW', self.cancel) self.step = 0 self.install_error: str | None = None + self.install_result: dict[str, str] | None = None + self.destination = default_resolve_root() self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) @@ -245,7 +320,7 @@ def add_heading(self, title: str, body: str) -> None: ttk.Label(self.content, text=title, font=('Segoe UI', 18, 'bold')).grid( row=0, column=0, sticky='w', pady=(0, 12) ) - ttk.Label(self.content, text=body, wraplength=520, justify='left').grid( + ttk.Label(self.content, text=body, wraplength=640, justify='left').grid( row=1, column=0, sticky='nw' ) @@ -275,13 +350,41 @@ def welcome_step(self) -> None: def ready_step(self) -> None: self.add_heading( 'Ready to Install', - 'Click Install to copy OpenBeat into the standard DaVinci Resolve Fusion support folder and configure the bundled runtime.', + 'OpenBeat will be copied into a DaVinci Resolve Fusion support folder and configured to use the bundled runtime.', ) summary = ttk.LabelFrame(self.content, text='Install summary', padding=14) summary.grid(row=2, column=0, sticky='ew', pady=(24, 0)) summary.columnconfigure(1, weight=1) ttk.Label(summary, text='Destination').grid(row=0, column=0, sticky='nw', padx=(0, 12)) - ttk.Label(summary, text=str(default_resolve_root()), wraplength=400).grid(row=0, column=1, sticky='w') + ttk.Label(summary, text=str(self.destination), wraplength=500).grid(row=0, column=1, sticky='w') + ttk.Button(summary, text='Copy', command=lambda: self.copy_to_clipboard(str(self.destination))).grid(row=0, column=2, padx=(8, 0)) + + ttk.Label(summary, text='Runtime').grid(row=1, column=0, sticky='nw', padx=(0, 12), pady=(10, 0)) + ttk.Label(summary, text='Bundled OpenBeat runtime, no separate Python setup needed.', wraplength=470).grid( + row=1, column=1, columnspan=2, sticky='w', pady=(10, 0) + ) + + if resolve_process_running(): + warning = ttk.Label( + self.content, + text='DaVinci Resolve appears to be running. Close it before installing so the script menu reloads cleanly.', + wraplength=640, + foreground='#8a4b00', + ) + warning.grid(row=3, column=0, sticky='w', pady=(16, 0)) + + def copy_to_clipboard(self, value: str) -> None: + self.clipboard_clear() + self.clipboard_append(value) + self.update_idletasks() + + def open_install_folder(self) -> None: + result = self.install_result or {'destination': str(self.destination)} + open_path(Path(result['destination'])) + + def open_log_location(self) -> None: + result = self.install_result or {'log_path': str(default_log_path())} + open_path(Path(result['log_path'])) def installing_step(self) -> None: self.add_heading('Installing OpenBeat', 'Please wait while setup copies files into Resolve.') @@ -297,12 +400,34 @@ def finish_step(self) -> None: 'Installation Did Not Complete', f'OpenBeat could not be installed.\\n\\n{self.install_error}', ) + actions = ttk.Frame(self.content) + actions.grid(row=2, column=0, sticky='w', pady=(24, 0)) + ttk.Button(actions, text='Open install folder', command=self.open_install_folder).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(actions, text='Open log folder', command=self.open_log_location).grid(row=0, column=1, padx=(0, 8)) return self.add_heading( 'OpenBeat Setup Complete', 'OpenBeat has been installed successfully. Restart DaVinci Resolve if it is open, then use Workspace > Scripts > OpenBeat.', ) + result = self.install_result or {} + summary = ttk.LabelFrame(self.content, text='Installed files', padding=14) + summary.grid(row=2, column=0, sticky='ew', pady=(24, 0)) + summary.columnconfigure(1, weight=1) + rows = [ + ('Scripts', result.get('utility_target', '')), + ('Runtime', result.get('runtime', '')), + ('Log file', result.get('log_path', str(default_log_path()))), + ] + for row_index, (label, value) in enumerate(rows): + ttk.Label(summary, text=label).grid(row=row_index, column=0, sticky='nw', padx=(0, 12), pady=(0, 8)) + ttk.Label(summary, text=value, wraplength=500).grid(row=row_index, column=1, sticky='w', pady=(0, 8)) + + actions = ttk.Frame(self.content) + actions.grid(row=3, column=0, sticky='w', pady=(18, 0)) + ttk.Button(actions, text='Open install folder', command=self.open_install_folder).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(actions, text='Open log folder', command=self.open_log_location).grid(row=0, column=1, padx=(0, 8)) + ttk.Button(actions, text='Copy destination', command=lambda: self.copy_to_clipboard(result.get('destination', ''))).grid(row=0, column=2) def go_back(self) -> None: if self.step > 0: @@ -325,13 +450,22 @@ def cancel(self) -> None: self.destroy() def start_install(self) -> None: + if resolve_process_running(): + should_continue = messagebox.askyesno( + 'DaVinci Resolve Is Running', + 'DaVinci Resolve appears to be running. Close it before installing for the cleanest result. Continue anyway?', + ) + if not should_continue: + return + self.install_error = None - destination = default_resolve_root() + self.install_result = None + destination = self.destination.expanduser() self.show_step(2) def worker() -> None: try: - install_openbeat(destination) + self.install_result = install_openbeat(destination) except Exception as exc: self.install_error = str(exc) self.after(0, self.install_finished) @@ -388,13 +522,14 @@ def main() -> int: clean() version = parse_version() - cli_binary = build_cli_binary(python_bin=args.python) outputs: list[Path] = [] if args.platform in ("macos", "all"): + cli_binary = build_cli_binary(platform="macos", python_bin=args.python) outputs.append(create_macos_pkg(cli_binary=cli_binary, version=version)) if args.platform in ("windows", "all"): + cli_binary = build_cli_binary(platform="windows", python_bin=args.python) outputs.append(create_windows_exe(cli_binary=cli_binary, version=version, python_bin=args.python)) for output in outputs: diff --git a/tests/test_analysis.py b/tests/test_analysis.py index ec37dd1..d152128 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1,10 +1,11 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from unittest import mock import soundfile as sf -from openbeat.analysis import estimate_grid, quantized_beats +from openbeat.analysis import cache_key, default_cache_dir, estimate_grid, quantized_beats from openbeat.artifacts import render_click_track @@ -28,6 +29,45 @@ def test_render_click_track_defaults_to_stereo(self) -> None: self.assertEqual(waveform.ndim, 2) self.assertEqual(waveform.shape[1], 2) + def test_render_click_track_is_stable_across_block_sizes(self) -> None: + with TemporaryDirectory() as tmpdir: + large_blocks = Path(tmpdir) / "large-blocks.wav" + small_blocks = Path(tmpdir) / "small-blocks.wav" + beats = [0.01, 0.018, 0.42, 0.99] + render_click_track(str(large_blocks), beats, 1.0, block_size=48000) + render_click_track(str(small_blocks), beats, 1.0, block_size=257) + large_waveform, large_sample_rate = sf.read(large_blocks) + small_waveform, small_sample_rate = sf.read(small_blocks) + + self.assertEqual(large_sample_rate, small_sample_rate) + self.assertEqual(large_waveform.shape, small_waveform.shape) + self.assertTrue((large_waveform == small_waveform).all()) + + def test_windows_cache_uses_local_appdata(self) -> None: + with mock.patch("openbeat.analysis.platform.system", return_value="Windows"): + with mock.patch.dict("os.environ", {"LOCALAPPDATA": "C:/Users/Parker/AppData/Local"}): + self.assertEqual( + default_cache_dir(), + Path("C:/Users/Parker/AppData/Local") / "OpenBeat" / "Cache", + ) + + def test_cache_key_changes_with_analysis_version_inputs(self) -> None: + with TemporaryDirectory() as tmpdir: + audio = Path(tmpdir) / "track.wav" + audio.write_bytes(b"not real audio") + + with mock.patch("openbeat.analysis.__version__", "1.0.0"): + first = cache_key(str(audio), target_sr=22050) + + with mock.patch("openbeat.analysis.__version__", "1.0.1"): + second = cache_key(str(audio), target_sr=22050) + + with mock.patch("openbeat.analysis.__version__", "1.0.0"): + third = cache_key(str(audio), target_sr=44100) + + self.assertNotEqual(first, second) + self.assertNotEqual(first, third) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_build_installers.py b/tests/test_build_installers.py new file mode 100644 index 0000000..323d6fb --- /dev/null +++ b/tests/test_build_installers.py @@ -0,0 +1,31 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from scripts import build_installers + + +class BuildInstallerTests(unittest.TestCase): + def test_windows_cli_runtime_is_onedir_and_windowed(self) -> None: + with TemporaryDirectory() as tmpdir: + dist_root = Path(tmpdir) / "dist" + + def fake_run(cmd: list[str], cwd: Path | None = None) -> None: + runtime = dist_root / "openbeat" + runtime.mkdir(parents=True) + (runtime / "openbeat.exe").write_text("") + + with mock.patch.object(build_installers, "DIST_ROOT", dist_root): + with mock.patch.object(build_installers, "run", side_effect=fake_run) as run: + result = build_installers.build_cli_binary(platform="windows", python_bin="py") + + command = run.call_args.args[0] + self.assertIn("--onedir", command) + self.assertIn("--windowed", command) + self.assertNotIn("--onefile", command) + self.assertEqual(result.name, "openbeat.exe") + + +if __name__ == "__main__": + unittest.main()