Add an Opus encode path to the source role - #116
Conversation
c00553e to
1ec53c8
Compare
# Conflicts: # include/sendspin/config.h
There was a problem hiding this comment.
🟡 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:17andCLAUDE.md:30still state that micro-opus is fetched/linked only for the player role and that the manifest gates it only onSENDSPIN_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 usingMAX_PACKET_BYTESso 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.
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.
There was a problem hiding this comment.
🟡 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:743false: 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:17still says the ESP manifest gates micro-opus only onSENDSPIN_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_MSis 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_MSis 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
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.
|
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. |
There was a problem hiding this comment.
🟢 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
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) inSourceRoleConfig; 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_sourceingest 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.