Skip to content
Draft
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
32 changes: 32 additions & 0 deletions .github/workflows/audio-py-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: audio-py

on:
workflow_call:

permissions:
contents: read

jobs:
test-and-build:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
os: [ubuntu-24.04, windows-2025, macos-15]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-python-env
with:
python-version: ${{ matrix.python }}
- name: Qualify codecs and build standalone artifacts
shell: bash
run: |
mise exec python@${{ matrix.python }} -- uv run --project py nox -f py/noxfile.py --force-python ${{ matrix.python }} -s test_audio_codecs
mise exec -- uv build --project integrations/audio-py --out-dir integrations/audio-py/dist
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: audio-py-${{ matrix.os }}-${{ matrix.python }}
path: integrations/audio-py/dist/*
retention-days: 5
8 changes: 7 additions & 1 deletion .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,18 @@ jobs:
mise exec python@${{ matrix.python-version }} -- python ./py/scripts/nox-matrix.py ${{ matrix.shard }} 6 \
--python-version '${{ matrix.python-version }}' --os '${{ matrix.os }}' \
--exclude-static-checks \
--exclude-session test_api_codegen
--exclude-session test_api_codegen \
--exclude-session test_audio_codecs

adk-py:
uses: ./.github/workflows/adk-py-test.yaml

langchain-py:
uses: ./.github/workflows/langchain-py-test.yaml

audio-py:
uses: ./.github/workflows/audio-py-test.yaml

upload-wheel:
needs:
- smoke
Expand Down Expand Up @@ -197,6 +201,7 @@ jobs:
- nox
- adk-py
- langchain-py
- audio-py
- upload-wheel
runs-on: ubuntu-24.04
timeout-minutes: 5
Expand Down Expand Up @@ -224,6 +229,7 @@ jobs:
check_result "nox" "${{ needs.nox.result }}"
check_result "adk-py" "${{ needs['adk-py'].result }}"
check_result "langchain-py" "${{ needs['langchain-py'].result }}"
check_result "audio-py" "${{ needs['audio-py'].result }}"
check_result "upload-wheel" "${{ needs['upload-wheel'].result }}"

if [ "$FAILED" -ne 0 ]; then
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/publish-audio-py.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Publish Python audio extension

on:
workflow_dispatch:
inputs:
sha:
description: Commit SHA containing the reviewed audio package version
required: true
type: string
dry_run:
description: Build and validate without publishing
type: boolean
default: true

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.sha }}
- uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4
with:
cache: true
experimental: true
- name: Validate and build
run: |
mise exec -- uv run --project py nox -f py/noxfile.py -s test_audio test_audio_codecs
mise exec -- uv build --project integrations/audio-py --out-dir integrations/audio-py/dist
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: braintrust-audio-dist
path: integrations/audio-py/dist/*
- name: Publish using PyPI trusted publishing
if: ${{ !inputs.dry_run }}
run: mise exec -- uv publish --trusted-publishing always integrations/audio-py/dist/*
20 changes: 20 additions & 0 deletions integrations/audio-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Braintrust audio

Install `braintrust-audio` alongside a core SDK containing the audio recording
contract to encode enabled session recordings as Opus in Ogg by default. The
package uses PyAV's bundled FFmpeg codecs. Each segment is an independent file
with exact playable duration.

Installing this extension does not enable recording. Capture requires the
framework adapter's audio opt-ins and privacy permission. Without the extension,
default recording warns once and uses raw PCM. Explicit Opus configuration
requires this extension. Codec and container are selected separately with
`AudioEncoding(codec="opus", container="ogg")`. PCM/WAV require no native codecs.

Audio uploads use `Attachment.upload()` directly. Span customizers and masking
apply to the trace records published afterward; removing an attachment reference
does not prevent or undo its upload.

Before publishing this distribution, set its minimum core dependency to the
release containing the new audio contract. The publish workflow defaults to a
dry run; configure the PyPI trusted publisher before the first release.
23 changes: 23 additions & 0 deletions integrations/audio-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=82.0.1"]
build-backend = "setuptools.build_meta"

[project]
name = "braintrust-audio"
version = "0.1.0"
description = "Optional audio codecs for Braintrust session recordings"
readme = "README.md"
requires-python = ">=3.10,<3.15"
license = "MIT"
authors = [{name = "Braintrust", email = "sdk@braintrustdata.com"}]
dependencies = ["braintrust>=0.49.1", "av==16.1.0"]

[project.urls]
Homepage = "https://www.braintrust.dev"
Repository = "https://github.com/braintrustdata/braintrust-sdk-python"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
braintrust_audio = ["py.typed"]
63 changes: 63 additions & 0 deletions integrations/audio-py/src/braintrust_audio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Versioned optional codec factory. Importing this module does not import PyAV."""

import io
from dataclasses import replace
from fractions import Fraction


def _encode(data, meta, config):
import av

destination = io.BytesIO()
layout = {1: "mono", 2: "stereo"}.get(len(meta.channels))
if layout is None or meta.sample_rate_hz != 48000:
raise ValueError("Opus recordings require mono/stereo 48 kHz PCM")
with av.open(destination, "w", format="ogg") as container:
stream = container.add_stream("libopus", rate=48000)
stream.layout = layout
stream.bit_rate = config.bitrate_bps
stream.codec_context.options = {
"application": config.application,
"compression_level": str(config.complexity),
}
stride = len(meta.channels) * 2
for start in range(0, meta.sample_count, 960):
count = min(960, meta.sample_count - start)
frame = av.AudioFrame(format="s16", layout=layout, samples=count)
frame.planes[0].update(data[start * stride : (start + count) * stride])
frame.sample_rate = 48000
frame.time_base = Fraction(1, 48000)
frame.pts = start
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode(None):
container.mux(packet)
return destination.getvalue()


def create_stack(config, *, contract_version=1):
if contract_version != 1 or (config.codec, config.container) != ("opus", "ogg"):
raise ValueError("Unsupported Braintrust audio contract or encoding")
# Qualify backend availability during explicit setup, before sessions begin.
import av

av.codec.Codec("libopus", "w")
from braintrust._audio.worker import run_native

async def opus_ogg(data, meta, next):
encoded = await run_native(_encode, data, meta, config)
return await next(
encoded,
replace(
meta,
mime_type="audio/ogg",
encoding="opus",
attributes={
"opus.bitrate_bps": config.bitrate_bps,
"opus.complexity": config.complexity,
"opus.application": config.application,
},
),
)

return (opus_ogg,)
Empty file.
68 changes: 68 additions & 0 deletions integrations/audio-py/tests/test_codec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import io
import math
from array import array

import av
import pytest
from braintrust._audio.middleware import encoding_stack, run_middleware
from braintrust._audio.types import AudioChannel, SegmentMeta
from braintrust.audio import AudioEncoding


@pytest.mark.asyncio
@pytest.mark.parametrize("samples", [1, 137, 960, 48137, 48000 * 60 + 137])
@pytest.mark.parametrize("channels", [1, 2])
async def test_independent_opus_segments_have_exact_playable_duration(samples, channels):
meta = SegmentMeta(0, 0, samples, 48000, tuple(AudioChannel((str(i),)) for i in range(channels)))
data = array("h", (1000 if i % channels == 0 else -1000 for i in range(samples * channels))).tobytes()
for config in (None, AudioEncoding(codec="opus", container="ogg")):
result = await run_middleware(encoding_stack(config), data, meta)
assert result.meta.encoding == "opus"
with av.open(io.BytesIO(result.data)) as container:
decoded = list(container.decode(audio=0))
assert sum(frame.samples for frame in decoded) == samples
assert all(len(frame.layout.channels) == channels for frame in decoded)


@pytest.mark.asyncio
async def test_opus_stereo_channels_remain_separate():
count = 48000
tones = [500, 1700]
data = array(
"h",
(round(12000 * math.sin(2 * math.pi * frequency * i / 48000)) for i in range(count) for frequency in tones),
).tobytes()
meta = SegmentMeta(0, 0, count, 48000, (AudioChannel(("input",)), AudioChannel(("output",))))
result = await run_middleware(encoding_stack(AudioEncoding()), data, meta)
channels = [[], []]
with av.open(io.BytesIO(result.data)) as container:
for frame in container.decode(audio=0):
assert frame.format.name == "fltp"
for channel in range(2):
samples = array("f")
samples.frombytes(bytes(frame.planes[channel]))
channels[channel].extend(samples[: frame.samples])
for channel in range(2):

def power(frequency):
real = sum(
value * math.cos(2 * math.pi * frequency * i / 48000) for i, value in enumerate(channels[channel])
)
imag = sum(
value * math.sin(2 * math.pi * frequency * i / 48000) for i, value in enumerate(channels[channel])
)
return real * real + imag * imag

assert power(tones[channel]) > 100 * power(tones[1 - channel])


@pytest.mark.asyncio
@pytest.mark.parametrize("bitrate,complexity,application", [(500, 0, "voip"), (512000, 10, "lowdelay")])
async def test_supported_opus_options(bitrate, complexity, application):
config = AudioEncoding(bitrate_bps=bitrate, complexity=complexity, application=application)
channels = 2 if bitrate > 256000 else 1
meta = SegmentMeta(0, 0, 48000, 48000, tuple(AudioChannel((str(i),)) for i in range(channels)))
result = await run_middleware(encoding_stack(config, channels=channels), bytes(96000 * channels), meta)
assert result.meta.attributes["opus.application"] == application
with av.open(io.BytesIO(result.data)) as container:
assert sum(frame.samples for frame in container.decode(audio=0)) == 48000
1 change: 1 addition & 0 deletions py/nox_session_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@


CONSTRAINTS = {
"test_audio_codecs": {"max_python_exclusive": "3.15"},
"test_ai_sdk": {"min_python": "3.12"},
# Python 3.15 gaps below are upstream packaging, not SDK code: dependencies
# without cp315 wheels whose sdists cannot build yet (mostly PyO3 < 0.27
Expand Down
9 changes: 9 additions & 0 deletions py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,15 @@ def test_audio(session):
_run_tests(session, "braintrust/_audio")


@nox.session()
def test_audio_codecs(session):
if sys.version_info >= (3, 15):
session.skip("PyAV wheels are qualified on Python 3.10–3.14")
_install_test_deps(session, "test-audio-codecs")
_session_install(session, "--no-deps", "-e", "../integrations/audio-py")
session.run("pytest", "../integrations/audio-py/tests", *session.posargs)


# Not a default session: it needs a wheel from `make build`, so a bare `nox`
# (`make test`) must not pick it up.
@nox.session(default=False)
Expand Down
5 changes: 5 additions & 0 deletions py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ test = [
"vcrpy>=8.5.0",
]

test-audio-codecs = [
{include-group = "test"},
"av==16.1.0; python_version<'3.15'",
]

# -- Session-specific auxiliary deps -------------------------------------------

test-openai-http2 = [
Expand Down
Loading
Loading