Skip to content

fix(discord): render real voice-message waveform + accurate duration_secs - #11359

Open
malaiwah wants to merge 1 commit into
NousResearch:mainfrom
malaiwah:fix/discord-voice-message-real-waveform
Open

fix(discord): render real voice-message waveform + accurate duration_secs#11359
malaiwah wants to merge 1 commit into
NousResearch:mainfrom
malaiwah:fix/discord-voice-message-real-waveform

Conversation

@malaiwah

Copy link
Copy Markdown
Contributor

Fixes #11358.

Summary

Discord voice messages currently ship with a flat waveform (bytes([128] * 256)). Discord's voice-bubble UI renders the waveform field as a loudness bar graph next to the play button, so a flat array looks like a featureless straight line.

This PR computes a real waveform from the audio file:

  1. Decode to mono 48 kHz s16le PCM via ffmpeg (already a Hermes voice dep).
  2. Window into min(256, max(1, round(duration_secs * 10))) buckets per the Discord spec (max 256 samples, at most one per 100 ms).
  3. Per-bucket RMS → dBFS → uint8 with a -60 dBFS perceptual floor. Silence maps to 0, full-scale maps to ~255.

Fallback paths all return a flat waveform (Discord still accepts it) so voice-message sending is never blocked by waveform trouble: numpy missing, ffmpeg missing / timeout (30 s) / decode error / permission / OOM / other exceptions, empty output, short PCM, very long clips (>10 min — don't buffer hundreds of MB of PCM), NaN / ±inf / negative duration.

Defense in depth on the subprocess argv:

  • -nostdin so ffmpeg never hangs on piped input.
  • Path wrapped as file:<path> so a leading-dash filename can't be interpreted as an ffmpeg flag (argv isolation already prevents shell injection; this closes the ffmpeg-flag-injection angle).

Test plan

  • pytest tests/gateway/test_discord_voice_waveform.py -v — 21/21 pass
  • pytest tests/gateway/ -k discord -p asyncio — 214 passed, 1 skipped, 0 failed (full Discord suite)
  • 21 cases cover: target-sample math (cap, scale, min 1), worked numeric example (a -20 dBFS tone must map to waveform byte ~170), silence → 0, full-scale → 255, varying loudness is non-flat, all fallback paths (short PCM, NaN/inf/negative/zero duration, >10 min cap, ffmpeg missing/error/timeout, empty output, post-decode numeric failure), and argv hardening (file: prefix, -nostdin)

Peer review

Peer-reviewed by a subagent against the Discord spec before shipping. Feedback addressed:

  • HIGH — leading-dash argv injection → file: prefix + -nostdin
  • HIGH — narrow except list → broad except Exception in both subprocess and post-decode numeric paths, all with logger.warning
  • MEDIUM — short-PCM reshape ValueError → explicit pcm.size < target_samples guard + regression test
  • MEDIUM — long-clip memory bust → 10-minute cap + test verifying ffmpeg isn't spawned past the cap
  • LOW — NaN/inf duration → math.isfinite guard + parametrized test
  • Nitnp.nan_to_num on the dBFS→uint8 mapping as belt-and-braces

Risk

Zero behavior change when anything goes wrong (same flat-128 output as before). Only visible difference is: for a healthy ffmpeg + numpy setup, voice-message bubbles now render an actual loudness graph. No new dependency — numpy is already under hermes-agent[voice] / [vad], and the helper falls back gracefully if absent.

🤖 Generated with Claude Code

@malaiwah
malaiwah force-pushed the fix/discord-voice-message-real-waveform branch from 9ab4701 to f8bbe77 Compare April 17, 2026 03:27
@malaiwah

malaiwah commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer findings addressed

New — operator opt-out. DISCORD_VOICE_MESSAGE_WAVEFORM=false (or 0/no/off) skips the ffmpeg + numpy pipeline entirely and ships a flat waveform. Saves cycles for high-volume voice-message bots and for hosts that don't have ffmpeg / numpy. Also mirrored as discord.voice_message_waveform: false in config.yaml using the existing YAML → env pattern. Default is true. Documented in messaging/discord.md and reference/environment-variables.md. Eight-value parametrized test covers the on/off/unset/whitespace-case combinations.

Full Discord test suite. pytest tests/gateway/ -k discord -p asyncio — 223 passed, 1 skipped, 0 failed, across three consecutive runs (30 waveform-specific cases + all preexisting tests).

@malaiwah
malaiwah force-pushed the fix/discord-voice-message-real-waveform branch from f8bbe77 to 9034786 Compare April 17, 2026 03:50
@malaiwah malaiwah changed the title fix(discord): render real voice-message waveform instead of flat 128 bytes fix(discord): render real voice-message waveform + accurate duration_secs Apr 17, 2026
@malaiwah

malaiwah commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Added — fix the "1:04 → 0:14" voice-bubble duration flicker

In-field observation: voice-message bubbles first rendered with a wildly wrong duration (e.g. "1:04" on a 14 s clip) and then silently corrected themselves a second later to the real length. That's Discord's UI showing the duration_secs we sent, then its backend re-deriving duration from the decoded audio and pushing an updated message object.

Root cause: mutagen.oggopus.OggOpus(audio_path).info.length only works for OGG Opus. Edge TTS / OpenAI TTS / ElevenLabs non-Opus can ship MP3 or WAV, so OggOpus(mp3_file) raises and we fall through to max(1.0, len(file_data) / 2000) — a rough byte-rate estimate that's frequently 4-5× off. For a 14s MP3 at ~64 kbps that formula returns ~64s, matching the reported "1:04."

Fix bundled into this PR (same attachment payload, same ffmpeg toolchain):

  1. New _probe_audio_duration_seconds helper — wraps ffprobe with file:<path> argv hardening and the same fallback discipline as the waveform helper. ffprobe demuxes the container through the real codec, so its duration matches what Discord's backend will derive → UI stays stable from the first render. mutagen and byte-rate formula remain as fallbacks for deployments without ffprobe.

  2. Offloaded via await asyncio.to_thread(...) in send_voice, same pattern as the waveform helper. No event-loop blocking.

  3. ffprobe runs regardless of DISCORD_VOICE_MESSAGE_WAVEFORM — the probe is O(1) in file size and the cost of a one-time metadata read is trivial. The toggle only gates the heavier ffmpeg+numpy waveform work.

  4. New TestProbeAudioDuration class (7 cases) + two end-to-end tests asserting send_voice prefers ffprobe and falls back when it's unavailable.

PR title updated to reflect the bundled scope. Full Discord suite: 237 passed, 1 skipped, 0 failed across 5 consecutive runs.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists platform/discord Discord bot adapter labels Apr 25, 2026

@teknium1 teknium1 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.

Thanks for addressing both the native waveform and duration fallback; the current Discord adapter still has the flat waveform and byte-rate duration fallback at plugins/platforms/discord/adapter.py:2650-2658.

Problems

  • The branch targets the pre-migration adapter and config bridge. Commit cc8e5ec2a moved gateway/platforms/discord.py to plugins/platforms/discord/adapter.py; current YAML handling belongs in _apply_yaml_config at plugins/platforms/discord/adapter.py:8229. Consequently, the added gateway/config.py:623 bridge will not run on current main.
  • The new test imports gateway.platforms.discord at tests/gateway/test_discord_voice_waveform.py:57, a module absent from current main after that migration.

Suggested changes

  • Salvage the helper and send_voice changes into plugins/platforms/discord/adapter.py, add the YAML translation to that plugin's _apply_yaml_config, and retarget the test imports/mocks to the plugin path.

Automated hermes-sweeper review.

Comment thread gateway/config.py Outdated
# voice_message_waveform: compute a real loudness waveform for
# voice messages (default true). Set false to skip ffmpeg +
# numpy work and always ship a flat 128-byte waveform.
if "voice_message_waveform" in discord_cfg and not os.getenv("DISCORD_VOICE_MESSAGE_WAVEFORM"):

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.

Current main no longer owns Discord YAML translation here: cc8e5ec2a moved it to plugins/platforms/discord/adapter.py:_apply_yaml_config (line 8229). Port this setting to that hook or the config value will not be applied.


_ensure_discord_mock()

from gateway.platforms.discord import ( # noqa: E402

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.

Current main removed this import path when cc8e5ec2a migrated the adapter to plugins.platforms.discord.adapter. Update this import and the test's patch targets to the plugin module so collection succeeds.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
@malaiwah
malaiwah force-pushed the fix/discord-voice-message-real-waveform branch from 9034786 to b7575db Compare August 4, 2026 02:46
@malaiwah

malaiwah commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Retargeted to current main HEAD

The original branch targeted the pre-migration gateway/platforms/discord.py path. The Discord adapter has since been migrated to plugins/platforms/discord/adapter.py (~10,362 lines). I've rebased the changes onto current main HEAD (91937a6dc) with the following retargeting:

Code changes (plugins/platforms/discord/adapter.py):

  • Added _probe_audio_duration_seconds() — ffprobe → mutagen → byte-rate fallback chain for accurate duration
  • Added _compute_voice_message_waveform() — RMS/dBFS-based real waveform computation (replaces flat bytes([128] * 256))
  • Added _voice_message_waveform_enabled() — config check helper
  • Updated send_voice() to use asyncio.to_thread() for both duration probing and waveform computation
  • Moved voice_message_waveform YAML→env bridge into _apply_yaml_config() (the migrated config function, replacing the old gateway/config.py block)

Tests (tests/gateway/test_discord_voice_waveform.py):

  • All imports retargeted from gateway.platforms.discordplugins.platforms.discord.adapter
  • Patch targets updated to plugins.platforms.discord.adapter.subprocess.run
  • PlatformConfig imported from gateway.config
  • 44 tests pass

Docs:

  • Updated website/docs/reference/environment-variables.md and website/docs/user-guide/messaging/discord.md with DISCORD_VOICE_MESSAGE_WAVEFORM env var and voice_message_waveform config key

Ready for re-review.

…secs

Discord's voice-message bubble renders a ``waveform`` field as a loudness
bar graph next to the play button. The old code shipped a flat
``bytes([128] * 256)`` — a featureless line that's the tell-tale sign of
a bot voice message.

Replace the flat waveform with real RMS/dBFS-based computation:
- Decode the audio to mono 48 kHz s16le PCM via ffmpeg (already a Hermes
  voice dependency)
- Window into target_samples buckets (one per 100ms, capped at 256)
- Compute per-bucket RMS, map to dBFS, then to uint8 on a [-60, 0] scale
- Any failure (missing ffmpeg, decode error, empty audio) falls back to
  the flat 128-waveform — Discord accepts it and the voice message still
  sends

Also fix the duration_secs probe: ffprobe → mutagen → byte-rate fallback
chain. The old mutagen-only path misreported non-OGG inputs (Edge TTS /
OpenAI TTS can ship MP3), causing the voice bubble to show "1:04" on a
14-second clip then silently correct itself a second later.

Operators can opt out via ``DISCORD_VOICE_MESSAGE_WAVEFORM=false`` to
skip the ffmpeg decode and always ship the flat fallback.

The waveform computation uses pure Python (struct + math) — no numpy
dependency. This keeps the feature available in all environments without
adding an optional dependency.

Retargeted from gateway/platforms/discord.py to plugins/platforms/discord/adapter.py
after the Discord platform migration.
@malaiwah
malaiwah force-pushed the fix/discord-voice-message-real-waveform branch from b7575db to b60b889 Compare August 8, 2026 01:09
@malaiwah

malaiwah commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased + CI failures fixed — ready for re-review

Rebased onto current main HEAD (b3aa561fa) and fixed all 9 CI test failures.

CI failures — root cause and fix

All 9 failures in tests/gateway/test_discord_voice_waveform.py traced to a single root cause: the waveform computation depended on numpy, which is not installed in the CI test environment. The import numpy failed silently and the function fell back to the flat bytes([128] * N) waveform — every test that checked waveform values got 128.

Fix: Replaced the numpy implementation with pure Python (struct + math, both already imported at the top of the adapter). No new dependency, no optional import, works in all environments. The computation is identical: per-bucket RMS → dBFS → uint8 mapping on a [-60, 0] scale.

teknium1's inline comments — both addressed

  1. gateway/config.py:623 — "Current main no longer owns Discord YAML translation here; port to plugins/platforms/discord/adapter.py:_apply_yaml_config." → ✅ The DISCORD_VOICE_MESSAGE_WAVEFORM YAML→env bridge is at adapter.py:10272-10276 in the _apply_yaml_config path. Nothing in gateway/config.py.

  2. tests/gateway/test_discord_voice_waveform.py:57 — "Update this import and patch targets to the plugin module." → ✅ Test imports from plugins.platforms.discord.adapter (line 61). All 18 patch targets reference plugins.platforms.discord.adapter.subprocess.run. No references to gateway.platforms.discord.

Test results

All 44 tests pass locally (was 35 passed / 9 failed before the fix):

44 passed in 11.48s

Current state

  • mergeable: True (no conflicts)
  • +909/−9 across 4 files, 1 commit
  • Based on current main HEAD b3aa561fa

Ready for re-review.

@andrexibiza

Copy link
Copy Markdown
Contributor

Discord Feature Parity & Alignment Campaign interlock: tracked by EPIC #79564. Original contributor lane; preservation and integration routing remain explicit in the campaign ledger.

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

Labels

P2 Medium — degraded but workaround exists platform/discord Discord bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord voice messages ship a flat waveform instead of real audio loudness

4 participants