Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/build-installers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ macOS:

Windows:

- Install folder: `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion`
- Runtime log: `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\logs\OpenBeat.log`
- Analysis cache: `%USERPROFILE%\.cache\openbeat`
- Analysis cache: `%LOCALAPPDATA%\OpenBeat\Cache`

## Technical Details

Expand Down
51 changes: 42 additions & 9 deletions openbeat/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand All @@ -135,34 +141,61 @@ 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"


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

Expand Down
51 changes: 44 additions & 7 deletions openbeat/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
12 changes: 10 additions & 2 deletions resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 .. ")."
Expand Down
Loading
Loading