diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 0c8429c..72d1999 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -19,11 +19,6 @@ phase = "hardening" maturity = "pre-production" rationale = "Foundations + SFU solid. LLM service, PTP hardware read, Avow attestation, Opus server NIF are stubs contradicting earlier 'done' claims. Migration in progress: V-lang removed, ReScript -> AffineScript pending." -[dogfooding-status] -# Populated 2026-04-19 against real on-disk integrations. Each entry must -# resolve to a grep-hit in the cited path. -vext-protocol-implementation = "2026-04-19 — vext protocol implemented in burble server, not in the vext repo (531 LOC: vext.ex 257 + vext_groove.ex 155 + vext_test.exs 119). Paths: server/lib/burble/verification/vext.ex, server/lib/burble/verification/vext_groove.ex, server/test/burble/verification/vext_test.exs" - [route-to-mvp] milestones = [ { name = "v0.1.0 to v0.4.0 — Foundation & Transport", completion = 100 }, @@ -45,8 +40,10 @@ signaling-relay = { status = "consolidated", canonical = "signaling/relay.js", r doc-reality-drift = [ "ROADMAP.adoc claims LLM Service DONE — is a stub (provider missing, parse_frame broken)", "ROADMAP.adoc claims Formal Proofs DONE — Avow attestation is data-type-only, no dependent-type enforcement", - "README.adoc PTP claim sub-microsecond assumes hardware — code falls back to system clock without NIF", - "ffi/zig coprocessor nif_audio_encode/decode are not real Opus (intentional SFU-opaque, but misleadingly named)" + "README.adoc PTP claim sub-microsecond assumes hardware — code falls back to system clock without NIF" +] +resolved-2026-04-16 = [ + "Opus naming/contract drift: Backend.audio_encode/4 + audio_decode/3 docstrings rewritten to state explicitly that they are PCM frame pack/unpack, NOT Opus. Added explicit Backend.opus_transcode/4 callback returning {:error, :not_implemented} on every backend (ElixirBackend, ZigBackend, SmartBackend, SNIFBackend). Added opus_available?/0 callback (always false). Pinned by opus_contract_test.exs." ] [critical-next-actions] diff --git a/ffi/zig/src/coprocessor/audio.zig b/ffi/zig/src/coprocessor/audio.zig index fb69091..28dae5b 100644 --- a/ffi/zig/src/coprocessor/audio.zig +++ b/ffi/zig/src/coprocessor/audio.zig @@ -3,7 +3,15 @@ // Burble Coprocessor — Audio kernel (Zig SIMD implementation). // // SIMD-accelerated audio processing operations: -// - PCM encode/decode (16-bit LE ↔ float, with SIMD clamping) +// - PCM frame pack/unpack (16-bit LE ↔ float, with SIMD clamping) +// NOTE: This is *framing*, not Opus compression. Real Opus transcoding +// requires linking libopus and is deferred — see STATE.a2ml [migration]. +// Burble is an E2EE-opaque SFU: clients Opus-encode in the browser's +// WebRTC stack; the server forwards ciphertext without decoding. These +// pack/unpack helpers are used only for recording, archive, and +// self-test loopback paths. The Elixir Backend exposes an explicit +// `opus_transcode/4` callback that returns {:error, :not_implemented} +// so callers intending real Opus fail loudly. // - Noise gate (vectorised threshold comparison) // - Echo cancellation (NLMS adaptive filter, SIMD dot product) // diff --git a/server/lib/burble/coprocessor/backend.ex b/server/lib/burble/coprocessor/backend.ex index b0ebd36..8ad76a1 100644 --- a/server/lib/burble/coprocessor/backend.ex +++ b/server/lib/burble/coprocessor/backend.ex @@ -16,11 +16,21 @@ # └── SmartBackend — dispatcher routing per-operation # # Kernel domains: -# Audio — Opus encode/decode, noise suppression, echo cancellation +# Audio — PCM frame pack/unpack (NOT Opus transcoding — see note below), +# noise suppression, echo cancellation # Crypto — AES-GCM frame encryption, Avow hash chains # IO — jitter buffer, packet loss concealment, adaptive bitrate # DSP — FFT, convolution, mixing matrix # Neural — ML-based noise suppression (keyboard/fan/dog removal) +# +# Opus transcoding is NOT performed server-side. Burble is an E2EE-opaque +# SFU: clients encode Opus in the browser's WebRTC stack; the server forwards +# ciphertext frames without decoding. The audio_encode/audio_decode callbacks +# below pack raw PCM into length-prefixed frames — they are used for +# recording/archive/benchmark paths only, not for transcoding live RTP. An +# explicit `opus_transcode/4` callback returns {:error, :not_implemented} to +# make this contract enforceable. Real Opus would require linking libopus; +# see STATE.a2ml [migration] for the deferred decision. defmodule Burble.Coprocessor.Backend do @moduledoc """ @@ -59,19 +69,30 @@ defmodule Burble.Coprocessor.Backend do @callback available?() :: boolean() # --------------------------------------------------------------------------- - # Audio kernel — Opus codec, noise gate, echo cancellation + # Audio kernel — PCM frame pack/unpack, noise gate, echo cancellation # --------------------------------------------------------------------------- @doc """ - Encode a raw PCM audio frame to Opus. + Pack raw PCM samples into a length-prefixed binary frame. + + **This is NOT Opus encoding.** Clients perform Opus encoding in the + browser's WebRTC stack; the server does not transcode live RTP. This + callback is used for recording, archive, and self-test loopback paths + where raw PCM framing is needed. + + The `bitrate` parameter is accepted for API stability but is currently + ignored — no compression is performed. Call `opus_transcode/4` explicitly + if you need real Opus (it will return `{:error, :not_implemented}` until + libopus is linked). ## Parameters * `pcm` — Raw PCM samples as a list of floats (normalised -1.0..1.0) - * `sample_rate` — Sample rate in Hz (typically 48000) + * `sample_rate` — Sample rate in Hz (typically 48000); informational * `channels` — Channel count (1 = mono, 2 = stereo) - * `bitrate` — Target bitrate in bits/sec (e.g. 32000) + * `bitrate` — Currently ignored; retained for API compatibility - Returns `{:ok, opus_binary}` or `{:error, reason}`. + Returns `{:ok, frame_binary}` or `{:error, reason}`. The binary is + round-trippable through `audio_decode/3`. """ @callback audio_encode( pcm :: [float()], @@ -81,16 +102,44 @@ defmodule Burble.Coprocessor.Backend do ) :: {:ok, binary()} | {:error, term()} @doc """ - Decode an Opus frame to raw PCM samples. + Unpack a length-prefixed PCM frame (produced by `audio_encode/4`) + back into normalised float samples. + + **This is NOT Opus decoding.** See `audio_encode/4` docs for the + SFU-opaque rationale. - Returns `{:ok, pcm_floats}` or `{:error, reason}`. + Returns `{:ok, pcm_floats}` or `{:error, :invalid_frame}`. """ @callback audio_decode( - opus_frame :: binary(), + pcm_frame :: binary(), sample_rate :: pos_integer(), channels :: 1 | 2 ) :: {:ok, [float()]} | {:error, term()} + @doc """ + Transcode raw PCM to a real Opus frame (or real Opus to PCM if `pcm` is a + binary starting with the Opus TOC). + + **Currently returns `{:error, :not_implemented}` on all backends.** + This callback exists so that callers intending real Opus transcoding fail + loudly rather than silently round-tripping raw PCM through + `audio_encode/4`. Implementing this requires linking libopus; the decision + is tracked in STATE.a2ml [migration]. + """ + @callback opus_transcode( + pcm_or_opus :: [float()] | binary(), + sample_rate :: pos_integer(), + channels :: 1 | 2, + bitrate :: pos_integer() + ) :: {:error, :not_implemented} + + @doc """ + Whether this backend can perform real Opus transcoding. + + Returns `false` on every backend until libopus is linked. + """ + @callback opus_available?() :: boolean() + @doc """ Apply noise gate to PCM samples. diff --git a/server/lib/burble/coprocessor/elixir_backend.ex b/server/lib/burble/coprocessor/elixir_backend.ex index a9d3d00..1b719f3 100644 --- a/server/lib/burble/coprocessor/elixir_backend.ex +++ b/server/lib/burble/coprocessor/elixir_backend.ex @@ -46,9 +46,11 @@ defmodule Burble.Coprocessor.ElixirBackend do @impl true def audio_encode(pcm, _sample_rate, channels, _bitrate) do - # Reference: pack PCM as 16-bit LE integers in a raw frame. - # Real Opus encoding requires the opus NIF or external library. - # This produces a PCM frame that can round-trip through audio_decode. + # PCM frame pack: clamp to [-1.0, 1.0], scale to i16 LE, length-prefix. + # NOT Opus compression — this round-trips raw PCM through audio_decode/3 + # for recording, archive, and self-test paths. Real Opus lives in the + # browser's WebRTC encoder; server-side Opus requires linking libopus and + # is gated behind opus_transcode/4 which returns {:error, :not_implemented}. samples = pcm |> Enum.map(fn sample -> @@ -66,8 +68,9 @@ defmodule Burble.Coprocessor.ElixirBackend do end @impl true - def audio_decode(opus_frame, _sample_rate, _channels) do - case opus_frame do + def audio_decode(pcm_frame, _sample_rate, _channels) do + # PCM frame unpack — inverse of audio_encode/4. NOT Opus decode. + case pcm_frame do <<_ch::8, len::32-little, data::binary-size(len), _rest::binary>> -> samples = for <> do @@ -81,6 +84,19 @@ defmodule Burble.Coprocessor.ElixirBackend do end end + @impl true + def opus_transcode(_pcm_or_opus, _sample_rate, _channels, _bitrate) do + # Real Opus transcoding is not implemented server-side by design + # (SFU-opaque E2EE model). Linking libopus is a deferred decision + # tracked in STATE.a2ml [migration]. Callers wanting real Opus must + # either (a) rely on the browser's WebRTC Opus encoder/decoder, or + # (b) request libopus integration to be added to this backend. + {:error, :not_implemented} + end + + @impl true + def opus_available?, do: false + @impl true def audio_noise_gate(pcm, threshold_db) do # Convert dB threshold to linear amplitude. diff --git a/server/lib/burble/coprocessor/smart_backend.ex b/server/lib/burble/coprocessor/smart_backend.ex index a7f2fda..b118daa 100644 --- a/server/lib/burble/coprocessor/smart_backend.ex +++ b/server/lib/burble/coprocessor/smart_backend.ex @@ -66,14 +66,25 @@ defmodule Burble.Coprocessor.SmartBackend do @impl true def audio_encode(pcm, sample_rate, channels, bitrate) do + # PCM frame pack (NOT Opus transcoding — see Backend behaviour docs). zig_or_elixir().audio_encode(pcm, sample_rate, channels, bitrate) end @impl true - def audio_decode(opus_frame, sample_rate, channels) do - zig_or_elixir().audio_decode(opus_frame, sample_rate, channels) + def audio_decode(pcm_frame, sample_rate, channels) do + # PCM frame unpack (NOT Opus decoding). + zig_or_elixir().audio_decode(pcm_frame, sample_rate, channels) end + @impl true + def opus_transcode(pcm_or_opus, sample_rate, channels, bitrate) do + # Always returns {:error, :not_implemented} — neither backend links libopus. + zig_or_elixir().opus_transcode(pcm_or_opus, sample_rate, channels, bitrate) + end + + @impl true + def opus_available?, do: false + @impl true def audio_noise_gate(pcm, threshold_db) do # 1.2x Zig advantage — marginal but consistent. diff --git a/server/lib/burble/coprocessor/snif_backend.ex b/server/lib/burble/coprocessor/snif_backend.ex index 9994949..f373e31 100644 --- a/server/lib/burble/coprocessor/snif_backend.ex +++ b/server/lib/burble/coprocessor/snif_backend.ex @@ -289,11 +289,18 @@ defmodule Burble.Coprocessor.SNIFBackend do do: ZigBackend.audio_encode(pcm, sample_rate, channels, bitrate) @impl true - def audio_decode(opus_frame, sample_rate, channels), - do: ZigBackend.audio_decode(opus_frame, sample_rate, channels) + def audio_decode(pcm_frame, sample_rate, channels), + do: ZigBackend.audio_decode(pcm_frame, sample_rate, channels) @impl true - def audio_noise_gate(pcm, threshold_db), + def opus_transcode(pcm_or_opus, sample_rate, channels, bitrate), + do: ZigBackend.opus_transcode(pcm_or_opus, sample_rate, channels, bitrate) + + @impl true + def opus_available?, do: false + + @impl true + def audio_noise_gate(pcm, threshold_db), do: ZigBackend.audio_noise_gate(pcm, threshold_db) @impl true diff --git a/server/lib/burble/coprocessor/zig_backend.ex b/server/lib/burble/coprocessor/zig_backend.ex index b0419e2..041a572 100644 --- a/server/lib/burble/coprocessor/zig_backend.ex +++ b/server/lib/burble/coprocessor/zig_backend.ex @@ -104,6 +104,17 @@ defmodule Burble.Coprocessor.ZigBackend do end end + @impl true + def opus_transcode(_pcm_or_opus, _sample_rate, _channels, _bitrate) do + # Real Opus transcoding is not implemented in the Zig coprocessor either. + # The audio.zig kernel only frames PCM; no libopus is linked. Returning + # :not_implemented explicitly prevents silent round-trip-as-opus bugs. + {:error, :not_implemented} + end + + @impl true + def opus_available?, do: false + # --------------------------------------------------------------------------- # Crypto kernel — always Elixir (Erlang :crypto is native C already) # --------------------------------------------------------------------------- diff --git a/server/test/burble/coprocessor/opus_contract_test.exs b/server/test/burble/coprocessor/opus_contract_test.exs new file mode 100644 index 0000000..5d5e48c --- /dev/null +++ b/server/test/burble/coprocessor/opus_contract_test.exs @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# +# Opus contract regression test. +# +# Burble is an E2EE-opaque SFU. Clients perform Opus encoding in the +# browser's WebRTC stack; the server never transcodes live audio. The +# Backend.audio_encode/4 and Backend.audio_decode/3 callbacks pack raw PCM +# into a length-prefixed frame — they do NOT perform Opus compression. +# +# The explicit Backend.opus_transcode/4 callback exists so callers that +# *do* want real Opus transcoding fail loudly with {:error, :not_implemented} +# rather than silently receiving a round-tripped PCM frame. +# +# These tests pin that contract so a future change that silently adds real +# Opus to audio_encode (or removes opus_transcode) will break the suite. + +defmodule Burble.Coprocessor.OpusContractTest do + use ExUnit.Case, async: true + + alias Burble.Coprocessor.{ElixirBackend, SmartBackend, ZigBackend} + + describe "opus_transcode/4 contract" do + test "ElixirBackend returns {:error, :not_implemented}" do + pcm = [0.0, 0.5, -0.5, 0.25] + assert {:error, :not_implemented} = + ElixirBackend.opus_transcode(pcm, 48_000, 1, 32_000) + end + + test "ZigBackend returns {:error, :not_implemented}" do + pcm = [0.0, 0.5, -0.5, 0.25] + assert {:error, :not_implemented} = + ZigBackend.opus_transcode(pcm, 48_000, 1, 32_000) + end + + test "SmartBackend returns {:error, :not_implemented}" do + pcm = [0.0, 0.5, -0.5, 0.25] + assert {:error, :not_implemented} = + SmartBackend.opus_transcode(pcm, 48_000, 1, 32_000) + end + + test "opus_available?/0 is false on every backend" do + refute ElixirBackend.opus_available?() + refute ZigBackend.opus_available?() + refute SmartBackend.opus_available?() + end + end + + describe "audio_encode/4 is PCM framing, NOT Opus" do + test "round-trips raw PCM through audio_decode/3 (ElixirBackend)" do + pcm = [0.0, 0.5, -0.5, 0.25, -0.25] + {:ok, frame} = ElixirBackend.audio_encode(pcm, 48_000, 1, 32_000) + {:ok, decoded} = ElixirBackend.audio_decode(frame, 48_000, 1) + + # Exact round-trip within quantisation error confirms no Opus + # compression is being applied — real Opus is lossy and would lose + # precision well below the 16-bit quantisation floor we see here. + assert length(decoded) == length(pcm) + + Enum.zip(pcm, decoded) + |> Enum.each(fn {orig, dec} -> + assert_in_delta orig, dec, 1.0e-4 + end) + end + + test "bitrate parameter is ignored (ElixirBackend)" do + pcm = [0.0, 0.5, -0.5] + + {:ok, frame_low} = ElixirBackend.audio_encode(pcm, 48_000, 1, 8_000) + {:ok, frame_high} = ElixirBackend.audio_encode(pcm, 48_000, 1, 320_000) + + # If bitrate controlled a real codec, low-bitrate frames would be + # shorter than high-bitrate frames. Since this is PCM framing, the + # two outputs are byte-identical regardless of "bitrate". + assert frame_low == frame_high + end + + test "frame format is a stable 1-byte channel + 4-byte LE length + i16 LE PCM" do + # Two 16-bit samples × 1 channel, plus 1-byte channels header + + # 4-byte length field = 9 bytes total. + pcm = [0.5, -0.5] + {:ok, frame} = ElixirBackend.audio_encode(pcm, 48_000, 1, 32_000) + + assert byte_size(frame) == 1 + 4 + 2 * 2 + <> = frame + assert channels == 1 + assert len == 4 + end + end +end