virtual_output
An ESPHome output: platform with no pin. Your browser is the buzzer.
Point rtttl: at it instead of esp8266_pwm or ledc, open port 8082 on the
ESP in a browser, and the ringtone plays in a tab — next to a scope showing
exactly what the pin would have been doing. ESP8266 and ESP32. No extra
hardware, no speaker, no wiring.
output:
- platform: virtual_output
id: buzzer
port: 8082
rtttl:
id: ringtone
output: buzzer
gain: 60%
Why
Because a buzzer is a rehearsal you cannot repeat at your desk. You solder,
flash, listen, and start again. This component moves the rehearsal into a
browser tab — and, importantly, it is as unforgiving as the real thing. If
your ESP stutters, this stutters. If gain: 100% is silent on the bench, it is
silent here. A rehearsal that only reproduced the successes would be worth
nothing.
What travels on the wire is not audio. It is a timestamped transition:
[0xAA][flags][ts uint32 LE][freq uint16 LE][duty uint8] = 9 bytes
Roughly 100 bytes per second at peak. The timestamp is taken on the ESP,
inside update_frequency() / write_state(), before the network can add jitter
to it. The browser replays each transition on the Web Audio sample clock, so
network jitter shifts the whole melody instead of deforming its rhythm.
Install
external_components:
- source: <repository source>
components: [virtual_output]
refresh: 1d
Then browse to your ESP’s address on port 8082.
Configuration
| Option | Default | Notes |
|---|---|---|
id |
required | |
port |
8082 |
The only setting this component owns. |
inverted |
false |
From FLOAT_OUTPUT_SCHEMA. |
min_power / max_power |
From FLOAT_OUTPUT_SCHEMA. |
|
zero_means_zero |
false |
From FLOAT_OUTPUT_SCHEMA. |
Everything but port comes from the base schema for free, and
FloatOutput::set_level() applies it before write_state() is called. The
browser therefore receives the duty cycle the pin would carry, not the one
rtttl asked for.
An expected compile warning
WARNING Component rtttl is not known to work with the selected output type.
Harmless. rtttl/__init__.py carries a FINAL_VALIDATE_SCHEMA with an
allowlist, PWM_GOOD = ["esp8266_pwm", "ledc"]; anything else warns. Only an
upstream change removes it.
Reading the panel
The heartbeat (10/s, continuously, whether or not music is playing) is the
metronome. Every beat is stamped on the ESP, before the network can touch it,
which lets the panel separate the only two things that can go wrong instead of
blaming whichever is nearer to hand:
| Line | Means |
|---|---|
Beat rate / target |
Must read 10.0 / 10.0. Any sag is real. |
ESP loop worst |
Largest gap between beats as the ESP lived it. ~110 ms is healthy. |
Link jitter |
Arrival spread minus the ESP’s own period. The network, and nothing else. |
ESP repairs |
A transition overflowed the queue and was dropped. Must stay 0. |
Resyncs |
Abnormal here; the ESP closes on a short write rather than shift a frame. |
Late events |
Play-out shorter than the link jitter. Raise the slider; Margin min says by how much. |
Duty / amplitude |
At gain: 100% you should read 100% / 0% and hear nothing. |
“My notes are held too long”
Look at ESP loop worst before anything else. If it reads several hundred
milliseconds, your ESP’s main loop is freezing — a poor WiFi link is the usual
cause on ESP8266. rtttl derives its tempo from that same loop, so it freezes
too and holds the note.
A real buzzer on a GPIO does exactly the same thing. This component is not
adding the fault; it is showing it to you. Fixing it here would mean lying about
what the pin is doing.
Moving to real hardware
Replace the output: block. Nothing else changes — not rtttl:, not gain:,
not your automations:
output:
- platform: esp8266_pwm # or: ledc, on ESP32
id: buzzer
pin: D5 # or: GPIO19
Moving to a real speaker (rtttl: speaker:) is not this migration. That is
rtttl’s other mode — 16 kHz PCM instead of a PWM square — and it drops ESP8266.
This component does not cover it.
Design notes
- No undefined behaviour.
output::FloatOutputis a public API, and this
component owns its own state. Nothing is reached by casting to a class the
object is not an instance of. - One socket path for both targets. ESPHome’s
socketcomponent covers
ESP8266 through itslwip_tcpimplementation, so there is no
WiFiServer/socket::Socketsplit. The only#ifdefleft picks a string for
theX-Platformheader. Note that onlwip_tcp,ListenSocketandSocket
are distinct types:socket_ip()cannotlisten()there. - A queue, not a coalesced state. For two identical consecutive notes,
rtttlinserts a 10 ms gap with a blockingdelay()between the two
set_level()calls, so both land inside one of ourloop()passes. Entries
are fused only when they share a millisecond — which is exactly the
update_frequency()+set_level()pair, and nothing else. TCP_NODELAYon both sides. Every frame is 9 bytes, so all of them are
sub-MSS; Nagle would hold each note until the previous ACK returned.- The browser is a delayed mirror, so its eyes ride the same delayed clock as
its ears: the panel and the scope are scheduled at play-out time, not painted
on arrival.
Browser autoplay
Browsers require a user gesture before audio can start. The page asks at load —
Chrome grants it once this origin’s Media Engagement Index has been crossed, and
Web Audio use feeds that index, so it earns itself with use. Until then, the
LISTEN button (or a click anywhere on the page) opens it. MUTE silences the
ear, not the instrument: the scope keeps scrolling and the panel keeps counting.
License
GPLv3 for the C++, MIT for the Python — ESPHome’s own model. GitHub will show
NOASSERTION; so does esphome/esphome.