diff --git a/.github/workflows/build-rtc.yml b/.github/workflows/build-rtc.yml index d457eeef..52b8c10b 100644 --- a/.github/workflows/build-rtc.yml +++ b/.github/workflows/build-rtc.yml @@ -79,18 +79,13 @@ jobs: with: submodules: true - - uses: actions/setup-python@v4 - - - name: Install cibuildwheel - if: runner.os != 'macOS' - run: python3 -m pip install cibuildwheel==2.17.0 - - - name: Install cibuildwheel on macOS - if: runner.os == 'macOS' - run: python3 -m pip install --break-system-packages cibuildwheel==2.17.0 + - uses: actions/setup-python@v5 + id: setup-python + with: + python-version: "3.11" - name: Build wheels - run: python3 -m cibuildwheel --output-dir dist + run: pipx run --python '${{ steps.setup-python.outputs.python-path }}' cibuildwheel==3.3.1 --output-dir dist env: CIBW_ARCHS: ${{ matrix.archs }} @@ -120,9 +115,57 @@ jobs: name: rtc-release-sdist path: livekit-rtc/dist/*.tar.gz + + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) + needs: [build_wheels] + strategy: + fail-fast: false + matrix: + include: + # Linux x86_64 tests + - os: ubuntu-latest + python-version: "3.9" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.10" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.11" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.12" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.13" + artifact: rtc-release-ubuntu-latest + # macOS tests (arm64 runner) + - os: macos-latest + python-version: "3.9" + artifact: rtc-release-macos-latest + - os: macos-latest + python-version: "3.12" + artifact: rtc-release-macos-latest + # Windows tests + - os: windows-latest + python-version: "3.9" + artifact: rtc-release-windows-latest + - os: windows-latest + python-version: "3.12" + artifact: rtc-release-windows-latest + uses: ./.github/workflows/tests.yml + with: + os: ${{ matrix.os }} + python-version: ${{ matrix.python-version }} + artifact-name: ${{ matrix.artifact }} + secrets: + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + publish: name: Publish RTC release - needs: [build_wheels, make_sdist] + needs: [build_wheels, make_sdist, test] runs-on: ubuntu-latest permissions: id-token: write diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96a82017..a7c74e10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,45 +1,140 @@ name: Tests on: - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: + workflow_call: + inputs: + os: + description: "Runner OS (e.g., ubuntu-latest, macos-latest, windows-latest)" + required: true + type: string + python-version: + description: "Python version to test" + required: true + type: string + artifact-name: + description: "Name of the wheel artifact to download" + required: true + type: string + run-id: + description: "Workflow run ID to download artifacts from (optional, uses current run if not specified)" + required: false + type: string + secrets: + LIVEKIT_URL: + required: true + LIVEKIT_API_KEY: + required: true + LIVEKIT_API_SECRET: + required: true jobs: - tests: - name: Run tests - runs-on: ubuntu-latest + test: + name: Test (${{ inputs.os }}, Python ${{ inputs.python-version }}) + runs-on: ${{ inputs.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: submodules: true lfs: true - - name: Install uv + - uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + + - name: Install uv uses: astral-sh/setup-uv@v5 with: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install the project - run: uv sync --all-extras --dev + - name: Download livekit-rtc wheel (current run) + if: ${{ inputs.run-id == '' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: rtc-wheel - - uses: actions/setup-python@v6 + - name: Download livekit-rtc wheel (from specific run) + if: ${{ inputs.run-id != '' }} + uses: actions/download-artifact@v4 with: - python-version: '3.13' + name: ${{ inputs.artifact-name }} + path: rtc-wheel + run-id: ${{ inputs.run-id }} + github-token: ${{ github.token }} + + - name: Select compatible wheel (macOS) + if: runner.os == 'macOS' + id: select-wheel-macos + run: | + # macOS artifacts contain both x86_64 and arm64 wheels, select the right one + WHEEL=$(python3 -c " + import glob + import platform + import sys + + wheels = glob.glob('rtc-wheel/*.whl') + machine = platform.machine().lower() + + arch_map = { + 'x86_64': ['x86_64'], + 'arm64': ['arm64'], + } + patterns = arch_map.get(machine, [machine]) + + for wheel in wheels: + wheel_lower = wheel.lower() + if any(p in wheel_lower for p in patterns): + print(wheel) + sys.exit(0) + + print(f'No matching wheel found for {machine}', file=sys.stderr) + sys.exit(1) + ") + echo "wheel=$WHEEL" >> $GITHUB_OUTPUT - - name: Run tests + - name: Create venv and install dependencies (Unix) + if: runner.os == 'Linux' + run: | + uv venv .test-venv + source .test-venv/bin/activate + uv pip install rtc-wheel/*.whl ./livekit-api ./livekit-protocol + uv pip install pytest pytest-asyncio numpy matplotlib + + - name: Create venv and install dependencies (macOS) + if: runner.os == 'macOS' + run: | + uv venv .test-venv + source .test-venv/bin/activate + uv pip install "${{ steps.select-wheel-macos.outputs.wheel }}" + uv pip install ./livekit-api ./livekit-protocol + uv pip install pytest pytest-asyncio numpy matplotlib + + - name: Create venv and install dependencies (Windows) + if: runner.os == 'Windows' + run: | + uv venv .test-venv + $wheel = (Get-ChildItem rtc-wheel\*.whl)[0].FullName + uv pip install --python .test-venv $wheel .\livekit-api .\livekit-protocol + uv pip install --python .test-venv pytest pytest-asyncio numpy matplotlib + shell: pwsh + + - name: Run tests (Unix) + if: runner.os != 'Windows' env: LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} run: | - - uv run python ./livekit-rtc/rust-sdks/download_ffi.py --output livekit-rtc/livekit/rtc/resources - uv add ./livekit-rtc ./livekit-api ./livekit-protocol - uv run pytest . --ignore=livekit-rtc/rust-sdks + source .test-venv/bin/activate + pytest tests/ + + - name: Run tests (Windows) + if: runner.os == 'Windows' + env: + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + run: .test-venv\Scripts\python.exe -m pytest tests/ + shell: pwsh \ No newline at end of file diff --git a/livekit-rtc/hatch_build.py b/livekit-rtc/hatch_build.py index 839d3072..b274e46b 100644 --- a/livekit-rtc/hatch_build.py +++ b/livekit-rtc/hatch_build.py @@ -12,11 +12,81 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Custom build hook for platform-specific wheel tagging. + +This hook generates py3-none-{platform} wheels because the native FFI libraries +(.so/.dylib/.dll) don't use the Python C API - they're loaded via ctypes at +runtime. This makes them compatible with any Python 3.x version. + +Why not use sysconfig.get_platform()? + - On macOS, it returns the Python interpreter's compile-time deployment target, + not the MACOSX_DEPLOYMENT_TARGET from the environment that cibuildwheel sets. + +Why not let hatchling infer the tag? + - hatchling doesn't recognize bundled .so/.dylib/.dll as platform-specific + unless we explicitly set pure_python=False and provide the tag. +""" + +import os +import platform +import sys + from hatchling.builders.hooks.plugin.interface import BuildHookInterface class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): - """Force platform-specific wheel due to native libraries""" build_data["pure_python"] = False - build_data["infer_tag"] = True + build_data["infer_tag"] = False + build_data["tag"] = f"py3-none-{self._get_platform_tag()}" + + def _get_platform_tag(self): + """Get the wheel platform tag for the current/target platform.""" + if sys.platform == "darwin": + return self._get_macos_tag() + elif sys.platform == "linux": + # Return linux tag; cibuildwheel's auditwheel converts to manylinux + return f"linux_{platform.machine()}" + elif sys.platform == "win32": + return f"win_{self._normalize_arch(platform.machine())}" + else: + return f"{platform.system().lower()}_{platform.machine()}" + + def _get_macos_tag(self): + """Build macOS platform tag respecting cross-compilation settings. + + cibuildwheel sets MACOSX_DEPLOYMENT_TARGET and ARCHFLAGS when building. + We must use these rather than the host machine's values. + """ + target = os.environ.get("MACOSX_DEPLOYMENT_TARGET") + if not target: + # Fall back to current macOS version (for local dev builds) + target = platform.mac_ver()[0] + parts = target.split(".") + target = f"{parts[0]}.{parts[1] if len(parts) > 1 else '0'}" + + version_tag = target.replace(".", "_") + arch = self._get_target_arch() + return f"macosx_{version_tag}_{arch}" + + def _get_target_arch(self): + """Detect target architecture, respecting ARCHFLAGS for cross-compilation. + + cibuildwheel sets ARCHFLAGS="-arch arm64" or "-arch x86_64" when + cross-compiling on macOS. + """ + archflags = os.environ.get("ARCHFLAGS", "") + if "-arch arm64" in archflags: + return "arm64" + if "-arch x86_64" in archflags: + return "x86_64" + return self._normalize_arch(platform.machine()) + + def _normalize_arch(self, arch): + """Normalize architecture names to wheel tag format.""" + return { + "AMD64": "amd64", + "x86_64": "x86_64", + "arm64": "arm64", + "aarch64": "aarch64", + }.get(arch, arch.lower()) diff --git a/livekit-rtc/pyproject.toml b/livekit-rtc/pyproject.toml index 6218730d..84e5b53b 100644 --- a/livekit-rtc/pyproject.toml +++ b/livekit-rtc/pyproject.toml @@ -54,15 +54,16 @@ include = ["/livekit", "/rust-sdks"] [tool.cibuildwheel] build = "cp39-*" -skip = "*-musllinux_*" # not supported (libwebrtc is using glibc) - +skip = "*-musllinux_*" # not supported (libwebrtc requires glibc) before-build = "pip install requests && python rust-sdks/download_ffi.py --output livekit/rtc/resources" +# Note: manylinux_2_28 is the default in cibuildwheel 3.x, no explicit config needed + +# macOS deployment targets must match the FFI binaries (see rust-sdks/.github/workflows/ffi-builds.yml) +# x86_64 supports macOS 10.15+, arm64 requires macOS 11.0+ +[[tool.cibuildwheel.overrides]] +select = "*macosx_x86_64" +environment = { MACOSX_DEPLOYMENT_TARGET = "10.15" } -manylinux-x86_64-image = "manylinux_2_28" -manylinux-i686-image = "manylinux_2_28" -manylinux-aarch64-image = "manylinux_2_28" -manylinux-ppc64le-image = "manylinux_2_28" -manylinux-s390x-image = "manylinux_2_28" -manylinux-pypy_x86_64-image = "manylinux_2_28" -manylinux-pypy_i686-image = "manylinux_2_28" -manylinux-pypy_aarch64-image = "manylinux_2_28" \ No newline at end of file +[[tool.cibuildwheel.overrides]] +select = "*macosx_arm64" +environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ef507d5f..467a9a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ convention = "google" [tool.pytest.ini_options] +testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" addopts = ["--import-mode=importlib", "--ignore=examples"] diff --git a/livekit-api/tests/test_access_token.py b/tests/api/test_access_token.py similarity index 100% rename from livekit-api/tests/test_access_token.py rename to tests/api/test_access_token.py diff --git a/livekit-api/tests/test_webhook.py b/tests/api/test_webhook.py similarity index 100% rename from livekit-api/tests/test_webhook.py rename to tests/api/test_webhook.py diff --git a/livekit-rtc/tests/.gitattributes b/tests/rtc/fixtures/.gitattributes similarity index 100% rename from livekit-rtc/tests/.gitattributes rename to tests/rtc/fixtures/.gitattributes diff --git a/livekit-rtc/tests/test_audio.wav b/tests/rtc/fixtures/test_audio.wav similarity index 100% rename from livekit-rtc/tests/test_audio.wav rename to tests/rtc/fixtures/test_audio.wav diff --git a/livekit-rtc/tests/test_echo_capture.wav b/tests/rtc/fixtures/test_echo_capture.wav similarity index 100% rename from livekit-rtc/tests/test_echo_capture.wav rename to tests/rtc/fixtures/test_echo_capture.wav diff --git a/livekit-rtc/tests/test_echo_render.wav b/tests/rtc/fixtures/test_echo_render.wav similarity index 100% rename from livekit-rtc/tests/test_echo_render.wav rename to tests/rtc/fixtures/test_echo_render.wav diff --git a/livekit-rtc/tests/test_processed.wav b/tests/rtc/fixtures/test_processed.wav similarity index 100% rename from livekit-rtc/tests/test_processed.wav rename to tests/rtc/fixtures/test_processed.wav diff --git a/livekit-rtc/tests/test_apm.py b/tests/rtc/test_apm.py similarity index 89% rename from livekit-rtc/tests/test_apm.py rename to tests/rtc/test_apm.py index da3d7ab1..94795e3c 100644 --- a/livekit-rtc/tests/test_apm.py +++ b/tests/rtc/test_apm.py @@ -4,16 +4,18 @@ from livekit.rtc import AudioProcessingModule, AudioFrame +# Test fixture directory +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + def test_audio_processing(): sample_rate = 48000 num_channels = 1 frames_per_chunk = sample_rate // 100 - current_dir = os.path.dirname(os.path.abspath(__file__)) - capture_wav = os.path.join(current_dir, "test_echo_capture.wav") - render_wav = os.path.join(current_dir, "test_echo_render.wav") - output_wav = os.path.join(current_dir, "test_processed.wav") + capture_wav = os.path.join(FIXTURES_DIR, "test_echo_capture.wav") + render_wav = os.path.join(FIXTURES_DIR, "test_echo_render.wav") + output_wav = os.path.join(FIXTURES_DIR, "test_processed.wav") # Initialize APM with echo cancellation enabled apm = AudioProcessingModule( diff --git a/livekit-rtc/tests/test_e2e.py b/tests/rtc/test_e2e.py similarity index 100% rename from livekit-rtc/tests/test_e2e.py rename to tests/rtc/test_e2e.py diff --git a/livekit-rtc/tests/test_emitter.py b/tests/rtc/test_emitter.py similarity index 100% rename from livekit-rtc/tests/test_emitter.py rename to tests/rtc/test_emitter.py diff --git a/livekit-rtc/tests/test_mixer.py b/tests/rtc/test_mixer.py similarity index 89% rename from livekit-rtc/tests/test_mixer.py rename to tests/rtc/test_mixer.py index 086fa0cf..1ab75f4b 100644 --- a/livekit-rtc/tests/test_mixer.py +++ b/tests/rtc/test_mixer.py @@ -2,7 +2,6 @@ import numpy as np import pytest -import matplotlib.pyplot as plt from livekit.rtc import AudioMixer from livekit.rtc.utils import sine_wave_generator @@ -43,13 +42,6 @@ async def test_mixer_two_sine_waves(): mixed_signal = np.concatenate(mixed_signals) - plt.figure(figsize=(10, 4)) - plt.plot(mixed_signal[:1000]) - plt.title("Mixed Signal") - plt.xlabel("Sample") - plt.ylabel("Amplitude") - plt.show() - # Use FFT to analyze frequency components. fft = np.fft.rfft(mixed_signal) freqs = np.fft.rfftfreq(len(mixed_signal), 1 / SAMPLE_RATE) diff --git a/livekit-rtc/tests/test_resampler.py b/tests/rtc/test_resampler.py similarity index 91% rename from livekit-rtc/tests/test_resampler.py rename to tests/rtc/test_resampler.py index 83108de3..25079da8 100644 --- a/livekit-rtc/tests/test_resampler.py +++ b/tests/rtc/test_resampler.py @@ -3,10 +3,12 @@ import wave import os +# Test fixture directory +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + def test_audio_resampler(): - current_dir = os.path.dirname(os.path.abspath(__file__)) - wav_file_path = os.path.join(current_dir, "test_audio.wav") + wav_file_path = os.path.join(FIXTURES_DIR, "test_audio.wav") # Open the wave file with wave.open(wav_file_path, "rb") as wf_in: