Skip to content

Add an Opus encode path to the source role - #116

Draft
chrisuthe wants to merge 12 commits into
source-role/04-wire-testsfrom
source-role/05-opus
Draft

Add an Opus encode path to the source role#116
chrisuthe wants to merge 12 commits into
source-role/04-wire-testsfrom
source-role/05-opus

Conversation

@chrisuthe

@chrisuthe chrisuthe commented Sep 1, 2026

Copy link
Copy Markdown
Member

Part 5/6 of the source@v1 stack (tracker: #95).

What it adds: an Opus encode path for the source role via micro-opus (already a dependency — its encoder is compiled in): one RFC 6716 packet per chunk, encoder lookahead reflected in the chunk timestamps, fail-closed validation of opus-legal rates/frame durations, and codec linking gated on player OR source.

How it's used: set codec = OPUS (plus bitrate/complexity) in SourceRoleConfig; everything else is unchanged. Verified by encode→decode round-trip tests against the library's own decoder.

Validation: the Opus path ran end-to-end against Music Assistant's sendspin_source ingest on a dev MA server (same interop shims as part 3): one RFC 6716 packet per chunk decoded by the server, timestamp continuity within tens of microseconds across the stream.

@chrisuthe chrisuthe closed this Sep 3, 2026
@chrisuthe chrisuthe reopened this Sep 4, 2026
@chrisuthe chrisuthe added the enhancement New feature or request label Sep 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Valid 5 ms frames are rejected, defaults conflict with Opus validation, and encoding adds an avoidable persistent buffer and copy.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds Opus encoding to the source role with timestamp compensation, configuration validation, dependency gating, and round-trip tests.

Changes:

  • Implements chunk-based Opus encoding and lookahead handling.
  • Adds Opus source configuration and build integration.
  • Expands validation, wire-level, and round-trip coverage.
File summaries
File Description
tests/test_source_role.cpp Adds Opus validation, encoding, wire, and round-trip tests.
src/source_task.cpp Integrates Opus encoding and packet sizing.
src/source_role.cpp Validates Opus-specific configuration.
src/source_encoder_opus.h Declares the Opus encoder.
src/source_encoder_opus.cpp Implements Opus encoding and lookahead handling.
src/opus_state_location.h Centralizes Opus state placement.
src/decoder.cpp Reuses the shared Opus placement policy.
include/sendspin/config.h Exposes Opus source configuration.
idf_component.yml Enables micro-opus for source builds.
CMakeLists.txt Links micro-opus when source support is enabled.
cmake/sources.cmake Registers the Opus encoder source.
cmake/host.cmake Updates host codec dependency gating.
Review details

Suppressed comments (4)

idf_component.yml:15

  • This changes dependency gating, but docs/internals.md:17 and CLAUDE.md:30 still state that micro-opus is fetched/linked only for the player role and that the manifest gates it only on SENDSPIN_ENABLE_PLAYER. Update both descriptions to say micro-opus is required by player or source.
      # Opus decode for the player role, Opus encode for the source role.
      - if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True || $CONFIG{SENDSPIN_ENABLE_SOURCE} == True"

include/sendspin/config.h:328

  • The public documentation omits the valid 5 ms Opus frame duration, matching the validation bug rather than the codec and protocol contract. Include 5 ms in this list when correcting validation.
    /// [CHUNK_MIN_MS, CHUNK_MAX_MS]. OPUS accepts only 10, 20, 40, or 60 (one
    /// chunk is exactly one legal Opus frame), so the PCM default of 25 is rejected for OPUS

tests/test_source_role.cpp:375

  • This bound assertion is tautological because packet.size() is only 3840, so every successful encode is already <= 3840; it cannot validate the advertised 4000-byte packet capacity. Size the packet and assert using MAX_PACKET_BYTES so the test actually protects the production constant instead of duplicating a dependent literal (docs/conventions.md:69-71).
        // Packet bound: nonempty and inside the 4000-byte scratch (libopus's recommended
        // maximum) at the default bitrate.
        ASSERT_GT(written, 0U);
        ASSERT_LE(written, 4000U);

tests/test_source_role.cpp:549

  • This expectation locks in rejection of 5 ms even though 5 ms is a legal Opus frame and meets the source protocol's minimum. It should be an accepting boundary case once validation is corrected.
    EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.chunk_duration_ms = 5; })));
  • Files reviewed: 12/12 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread include/sendspin/config.h
Comment thread src/source_encoder_opus.cpp Outdated
Comment thread src/source_role.cpp Outdated
Comment thread include/sendspin/config.h
Encode straight into the payload area now that the aligned PCM copy
already satisfies the in == out contract, dropping the 4 KB packet
scratch and its per-chunk copy; a small payload capacity acts as
libopus's native hard cap (degrade to fit) and the capacity test pins
that contract. 5 ms joins the accepted Opus chunk durations (a legal
frame at the spec's minimum), the packet-bound test asserts against
MAX_PACKET_BYTES itself, and write_audio's doc scopes the untouched-
bytes promise to PCM.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical initialization and micro-opus stack-safety issues, plus a moderate configuration-documentation inconsistency, remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (5)

cmake/host.cmake:75

  • This condition makes docs/integration-guide.md:743 false: with the source role enabled, disabling the player still fetches and links micro-opus. Update the build-options documentation to distinguish player-only micro-flac from micro-opus, which is required by either player or source.
    if(SENDSPIN_ENABLE_PLAYER OR SENDSPIN_ENABLE_SOURCE)

idf_component.yml:15

  • docs/internals.md:17 still says the ESP manifest gates micro-opus only on SENDSPIN_ENABLE_PLAYER. This new source-role condition invalidates that architecture description; document that micro-flac is player-only while micro-opus is gated on player or source.
      # Opus decode for the player role, Opus encode for the source role.
      - if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True || $CONFIG{SENDSPIN_ENABLE_SOURCE} == True"

include/sendspin/config.h:330

  • The public config documentation still calls 25 ms the PCM default, while DEFAULT_CHUNK_MS is 20 ms. This makes the field contract internally inconsistent.
    /// @brief Outbound chunk duration in milliseconds, validated against the spec bounds
    /// [CHUNK_MIN_MS, CHUNK_MAX_MS]. OPUS accepts only 5, 10, 20, 40, or 60 (one
    /// chunk is exactly one legal Opus frame), so the PCM default of 25 is rejected for OPUS

src/source_role.cpp:72

  • This comment still says the PCM default is 25 ms, but DEFAULT_CHUNK_MS is now 20 ms. Remove the obsolete default reference so the validation rationale matches the current config.
            // One chunk is one opus_encode() call, so it must be one legal frame; the PCM
            // default of 25 is deliberately not remapped -- fail closed beats silent repair

tests/test_source_role.cpp:173

  • This test comment also retains the obsolete 25 ms default; the production default is now 20 ms. Update it so the test description does not contradict the configuration it documents.
/// Baseline opus source config: 48 kHz stereo 16-bit, 20 ms chunks (one legal Opus frame; the
/// PCM chunk default of 25 ms is invalid for opus).
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/source_task.cpp
Comment thread src/source_task.cpp
Kconfig now refuses the source role under OPUS_USE_ALLOCA (the task
stack budget assumes the per-thread pseudostack) and under the
non-thread-safe pseudostack when the player role is also enabled (one
shared arena, two concurrent codec threads). Stale references to the
old 25 ms default are gone from the validation comment, the config
reference, and the opus test baseline.
@chrisuthe

Copy link
Copy Markdown
Member Author

On the suppressed items: the three stale references to the old 25 ms default (validation comment, config reference, opus test baseline) are fixed in c8a939a. The two dependency-documentation claims are already correct in part 6 (#117), where the stack's docs land: the integration guide's build-options section and internals both state micro-flac is player-only while micro-opus is gated on player or source.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The Opus path is consistently validated, integrated, bounded for embedded use, documented, and covered by focused unit and wire-level tests.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants