Skip to content

feat(audio): add AudioModule for issue #1932#2507

Open
GuoZhuoRan wants to merge 6 commits into
dimensionalOS:mainfrom
GuoZhuoRan:feat/audio-module-1932
Open

feat(audio): add AudioModule for issue #1932#2507
GuoZhuoRan wants to merge 6 commits into
dimensionalOS:mainfrom
GuoZhuoRan:feat/audio-module-1932

Conversation

@GuoZhuoRan

Copy link
Copy Markdown

Adds mic audio capture and chunked publishing as AudioStamped on an Out stream, mirroring CameraModule. Validated on macOS Apple Silicon at 50 Hz / 20 ms frames with both synthetic (sine tone) and real mic sources.

  • dimos/msgs/audio_msgs/AudioStamped.py: Python overlay wrapping foxglove_msgs.RawAudio for LCM encode/decode, with from_pcm() and to_numpy() helpers. Flags that builtin_interfaces.Time (not std_msgs.Header) is the wire type, so frame_id is not preserved.
  • dimos/hardware/sensors/audio/module.py: AudioModule(Module) with AudioConfig(ModuleConfig), async def main() lifecycle, @rpc start/stop, @Skill record_clip.
  • examples/audio/validate_audio_module.py: LCM round-trip assert + live stream rate/timestamp validation.

Problem

Closes DIM-XXX

Solution

How to Test

Contributor License Agreement

  • I have read and approved the CLA.

Adds mic audio capture and chunked publishing as AudioStamped on an Out
stream, mirroring CameraModule. Validated on macOS Apple Silicon at
50 Hz / 20 ms frames with both synthetic (sine tone) and real mic sources.

- dimos/msgs/audio_msgs/AudioStamped.py: Python overlay wrapping
  foxglove_msgs.RawAudio for LCM encode/decode, with from_pcm() and
  to_numpy() helpers. Flags that builtin_interfaces.Time (not
  std_msgs.Header) is the wire type, so frame_id is not preserved.
- dimos/hardware/sensors/audio/module.py: AudioModule(Module) with
  AudioConfig(ModuleConfig), async def main() lifecycle, @rpc start/stop,
  @Skill record_clip.
- examples/audio/validate_audio_module.py: LCM round-trip assert +
  live stream rate/timestamp validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a full audio subsystem — AudioModule, SpeakerModule, SpeechToTextModule, TextToSpeechModule, and FunVoiceEffectsModule — along with AudioStamped as the wire type and a validation script. The design mirrors CameraModule with @rpc start/stop, a @skill record_clip, and LCM serialisation via foxglove_msgs.RawAudio.

  • AudioModule captures mic PCM via sounddevice (or a synthetic 440 Hz sine) and publishes chunked AudioStamped on an Out stream at the configured frame rate.
  • SpeechToTextModule buffers audio, applies soft VAD and AEC, segments it, and transcribes with whisper.cpp / faster-whisper / openai-whisper.
  • TextToSpeechModule synthesises speech via OpenAI TTS, macOS say, or pyttsx3, chunking the output back into AudioStamped.

Confidence Score: 3/5

The audio pipeline works in the happy path but has a subscription leak that silently accumulates callbacks on re-start, and previously flagged timestamp and thread-safety issues remain unaddressed.

The start() methods on SpeechToTextModule, SpeakerModule, and TextToSpeechModule all overwrite their _unsub/_unsubs field without first calling the previous unsubscribe functions. Any restart cycle leaks the old callbacks permanently.

dimos/hardware/sensors/audio/module.py — specifically the start() methods of SpeechToTextModule (line 478), SpeakerModule (line 302), and TextToSpeechModule (line 938), and the _on_tts_active handler (line 511).

Important Files Changed

Filename Overview
dimos/hardware/sensors/audio/module.py New 1598-line file adding AudioModule, SpeakerModule, SpeechToTextModule, TextToSpeechModule, and FunVoiceEffectsModule. Contains subscription-overwrite leak in start() methods and identical if/else branches in _on_tts_active.
dimos/msgs/audio_msgs/AudioStamped.py New AudioStamped message type wrapping foxglove_msgs.RawAudio with LCM encode/decode, from_pcm() factory, and to_numpy() helpers.
examples/audio/validate_audio_module.py LCM round-trip and live stream rate/timestamp validation script. Defaults to synthetic mode.
dimos/robot/all_blueprints.py Auto-generated blueprint registry updated to include audio-speech-loopback and demo-audio blueprints.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Mic as Mic / PortAudio
    participant AM as AudioModule
    participant STT as SpeechToTextModule
    participant TTS as TextToSpeechModule
    participant FX as FunVoiceEffectsModule
    participant SP as SpeakerModule

    Mic->>AM: PCM callback (_sd_callback)
    AM->>AM: AudioStamped.from_pcm()
    AM-->>STT: audio Out to mic_audio
    STT->>STT: VAD + AEC + segment buffer
    STT-->>TTS: text Out to speech_text
    TTS->>TTS: synthesise (OpenAI / say / pyttsx3)
    TTS-->>STT: tts_active_signal, recent_tts_text, tts_reference_audio
    TTS-->>FX: tts_audio_raw
    FX->>FX: pitch / time-stretch
    FX-->>SP: tts_audio
    SP->>SP: sounddevice.OutputStream.write()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Mic as Mic / PortAudio
    participant AM as AudioModule
    participant STT as SpeechToTextModule
    participant TTS as TextToSpeechModule
    participant FX as FunVoiceEffectsModule
    participant SP as SpeakerModule

    Mic->>AM: PCM callback (_sd_callback)
    AM->>AM: AudioStamped.from_pcm()
    AM-->>STT: audio Out to mic_audio
    STT->>STT: VAD + AEC + segment buffer
    STT-->>TTS: text Out to speech_text
    TTS->>TTS: synthesise (OpenAI / say / pyttsx3)
    TTS-->>STT: tts_active_signal, recent_tts_text, tts_reference_audio
    TTS-->>FX: tts_audio_raw
    FX->>FX: pitch / time-stretch
    FX-->>SP: tts_audio
    SP->>SP: sounddevice.OutputStream.write()
Loading

Reviews (6): Last reviewed commit: "fix(audio): add OpenAI TTS timeout + fai..." | Re-trigger Greptile

Comment thread dimos/hardware/sensors/audio/module.py
Comment thread dimos/hardware/sensors/audio/module.py
Comment thread dimos/hardware/sensors/audio/module.py
Comment on lines +183 to +205
@skill
def record_clip(self, seconds: float = 1.0) -> bytes:
"""Record and return a clip of raw PCM audio.

Collects frames from the live audio stream for `seconds` seconds and
returns them concatenated as raw S16LE PCM bytes.
"""
import threading

buf: list[bytes] = []
done = threading.Event()
collected = [0.0]

def on_frame(msg: AudioStamped) -> None:
buf.append(msg.data)
collected[0] += self.config.frame_ms / 1000.0
if collected[0] >= seconds:
done.set()

unsub = self.audio.subscribe(on_frame)
done.wait(timeout=seconds + 2.0)
unsub()
return b"".join(buf)

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.

P2 record_clip silently returns empty bytes if the module is not running

If record_clip is called before start() or after stop(), no frames will ever arrive, done.wait will time out after seconds + 2.0 seconds, and the method returns b"" with no error or log message. Callers have no way to distinguish a successful empty recording from a misconfigured call. At minimum, a log warning on timeout (or a raised exception) would surface the problem.

Comment on lines +163 to +168
def __repr__(self) -> str:
n_samples = len(self.data) // (2 if "16" in self.sample_format else 4)
return (
f"AudioStamped(rate={self.sample_rate}, ch={self.channels}, "
f"fmt={self.sample_format}, samples={n_samples}, ts={self.ts:.6f})"
)

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.

P2 The n_samples heuristic does not divide by self.channels, so for multi-channel audio the repr reports total interleaved samples (e.g. 320 for 20 ms of stereo 16 kHz) rather than samples per channel (160). The existing byte-width check ("16" in self.sample_format) also silently falls back to 4 bytes/sample for any unknown format string, which could produce a nonsensical count.

Suggested change
def __repr__(self) -> str:
n_samples = len(self.data) // (2 if "16" in self.sample_format else 4)
return (
f"AudioStamped(rate={self.sample_rate}, ch={self.channels}, "
f"fmt={self.sample_format}, samples={n_samples}, ts={self.ts:.6f})"
)
def __repr__(self) -> str:
bytes_per_sample = 2 if "16" in self.sample_format else 4
n_frames = len(self.data) // (bytes_per_sample * self.channels)
return (
f"AudioStamped(rate={self.sample_rate}, ch={self.channels}, "
f"fmt={self.sample_format}, frames={n_frames}, ts={self.ts:.6f})"
)

audio metadata. Serialises to/from foxglove_msgs.RawAudio on the wire.
"""

msg_name = "foxglove_msgs.RawAudio" # wire type used for LCM

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we don't use foxglove, where does this come from?

@GuoZhuoRan GuoZhuoRan Jun 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Foxglove is not a new dependency; it's already mirrored into dimos_lcm, and RawAudio is the only audio type in there, so I reused it. Left a note that it's a stand-in pending a native Header-bearing type.

Comment thread dimos/msgs/audio_msgs/AudioStamped.py Outdated
def lcm_encode(self) -> bytes:
"""Encode to foxglove_msgs.RawAudio wire bytes.

NOTE: frame_id and seq from self.header are NOT preserved (the wire

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ros2 header has no seq

why not preserve frame_id?

@GuoZhuoRan GuoZhuoRan Jun 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

My comment is wrong; I will fix this issue. Frame_id does exist, but based on the RawAudio format, it only carries a timestamp; there is no frame_id on the wire type to put in. Preserving a frame_id means adding a header-bearing audio type to dimos-lcm, and we can discuss it today

- Remove all mentions of `seq` (ROS2 std_msgs/Header has no seq field)
- Reword frame_id note: dropped because RawAudio has no frame_id field
  on the wire, not by design choice
- Mark foxglove_msgs.RawAudio as a temporary stand-in pending team
  decision on a native Header-bearing LCM type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ts: float | None = None,
) -> AudioStamped:
"""Construct from raw PCM bytes."""
t = ts if ts is not None else time.monotonic()

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.

P1 The from_pcm factory's fallback timestamp uses time.monotonic(), which returns an opaque system-relative counter (seconds since boot) rather than a Unix wall-clock time. Any caller that omits the ts argument — including external consumers of this public API — will create an AudioStamped whose ts field is near 0–86400 rather than near the Unix epoch (~1.7 × 10⁹). This makes Timestamped.dt() return a date in 1970 and breaks cross-stream alignment with any module that uses time.time().

Suggested change
t = ts if ts is not None else time.monotonic()
t = ts if ts is not None else time.time()

GuoZhuoRan and others added 2 commits June 17, 2026 14:01
Add demo_audio blueprint to module.py and regenerate all_blueprints.py
so AudioModule is accessible via:
  dimos run demo-audio          (blueprint)
  dimos run audio-module        (standalone module)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines +706 to +714
(TextToSpeechModule, "text", "speech_text"),
(TextToSpeechModule, "audio", "tts_audio"),
(SpeakerModule, "audio", "tts_audio"),
]
)


demo_audio = autoconnect(
AudioModule.blueprint(),

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.

P1 TextToSpeechModule publishes frames with time.monotonic() timestamps

Every chunk published from _worker_loop uses time.monotonic() as its timestamp. time.monotonic() returns a system-relative counter (seconds since boot), not a Unix wall-clock time. Downstream consumers calling Timestamped.dt() will get dates in 1970, and cross-stream alignment with any module that uses time.time() (e.g., CameraModule) will fail. Replace with time.time() to match the rest of the stack.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants