[FEATURE] Add live waveform to streaming media #1

Merged
iamdoubz merged 2 commits from feat_waveform into main 2026-07-24 15:28:22 -05:00
8 changed files with 196 additions and 2 deletions
+14
View File
@@ -175,6 +175,20 @@ MEDIASESSION=true
# album art as tracks change.
DYNAMIC_META=true
# Real-time scrolling waveform (Web Audio), shown after the listener presses
# play. It routes playback through Web Audio, so if your STREAM_URL is on a
# DIFFERENT origin than the site it must send an Access-Control-Allow-Origin
# header — set WAVEFORM_STREAM_CORS=true once it does, or the waveform is
# auto-disabled to avoid silencing the audio. Same-origin streams need nothing.
WAVEFORM_ENABLED=false
WAVEFORM_HEIGHT=72
# Bar color; blank uses your THEME_ACCENT.
WAVEFORM_COLOR=""
# Seconds of history shown across the width before peaks scroll off.
WAVEFORM_WINDOW_SECONDS=30
# Set true only if a cross-origin stream sends CORS headers.
WAVEFORM_STREAM_CORS=false
# -----------------------------------------------------------------------------
# Listeners / history
+35
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="assets/icons/favicon.svg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
</p>
# Radio
A small, self-hostable website for an internet radio station. Point it at your
@@ -110,6 +114,27 @@ song has several Genius entries.
For a live stream the seek bar is replaced by a **LIVE** badge and an elapsed
counter, since seeking a live stream isn't meaningful.
### Waveform visualizer
`WAVEFORM_ENABLED=true` shows a real-time scrolling waveform that builds out as
the stream plays (it appears only after the listener presses play). It uses the
Web Audio API — an `AnalyserNode` fed by the `<audio>` element — and paints
peaks onto a `<canvas>` themed with your accent color. Tunable via
`WAVEFORM_HEIGHT`, `WAVEFORM_COLOR`, and `WAVEFORM_WINDOW_SECONDS`.
Two important caveats:
- **CORS.** Web Audio can only read the audio if the stream is same-origin as
the site, or the stream server sends `Access-Control-Allow-Origin` (and the
`<audio>` uses `crossorigin`, which the page adds automatically when active).
If your `STREAM_URL` is on a different host (e.g. a dedicated `stream.`
subdomain for the iOS/HTTP-2 fix) the waveform is **auto-disabled** unless you
set `WAVEFORM_STREAM_CORS=true` — because routing cross-origin audio through
Web Audio without CORS would silence it. Add the CORS header on the stream
host, then flip that flag on.
- **iOS.** Routing through Web Audio means playback then respects the iPhone's
ring/silent switch. It's off by default; test on-device before enabling.
### Offline fallback
When the now-playing provider reports the stream is offline, the player shows a
@@ -282,6 +307,16 @@ repackager and point iOS at that.
- Set `SHOW_ERRORS=false` and `DEBUG=false` in production.
- The lyrics/artwork endpoints echo back only sanitized data (plain-text lyrics, provider URLs).
## Screenshots
<p align="center">
<img src="assets/repo/radio-example-01.jpg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
</p>
<p align="center">
<img src="assets/repo/radio-example-02.jpg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
</p>
## Credits
Broadcast with [Icecast](https://icecast.org/) and
+4
View File
@@ -140,6 +140,10 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
/* native <audio> fallback (PLAYER_STYLE=simple / noscript) */
audio[controls] { width: 100%; outline: 0; }
/* --------------------------------------------------------------- waveform */
.waveform { width: 100%; display: block; border-radius: 8px; }
.waveform[hidden] { display: none; } /* [hidden] must beat display:block */
/* --------------------------------------------------------------- buttons */
.btn-accent {
min-width: 120px;
+99 -1
View File
@@ -40,7 +40,8 @@
historyBody: $('history-body'),
history: $('history'),
listeners: $('listeners'),
canvas: $('tint-canvas')
canvas: $('tint-canvas'),
waveform: $('waveform')
};
var ICON = {
@@ -179,6 +180,103 @@
}
}
/* ------------------------------------------------------ waveform viz */
// Real-time scrolling "building" waveform via Web Audio. Only runs when the
// server said it's safe (same-origin stream, or cross-origin + CORS), and
// only after playback starts (AudioContext needs a user gesture anyway).
var waveform = (function () {
var wcfg = CFG.waveform || {};
var canvas = el.waveform;
if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {} };
var g = canvas.getContext('2d');
var audioCtx = null, analyser = null, source = null, timeData = null, graphFailed = false;
var peaks = [], runningMax = 0, lastCommit = 0, rafId = null, running = false;
var dpr = window.devicePixelRatio || 1;
var BAR = 3, GAP = 1;
var winSec = Math.max(4, wcfg.window || 30);
var color = wcfg.color || getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e21d31';
function size() {
var cssW = canvas.clientWidth || (canvas.parentNode ? canvas.parentNode.clientWidth : 300) || 300;
var cssH = wcfg.height || 72;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function ensureGraph() {
if (audioCtx) return true;
if (graphFailed) return false;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { graphFailed = true; return false; }
try {
audioCtx = new AC();
source = audioCtx.createMediaElementSource(audio); // once per element
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
source.connect(analyser);
analyser.connect(audioCtx.destination); // keep audio audible
timeData = new Float32Array(analyser.fftSize);
} catch (e) {
graphFailed = true; audioCtx = null; // cross-origin blocked, etc.
return false;
}
return true;
}
function draw(ts) {
if (!running) return;
rafId = requestAnimationFrame(draw);
if (!analyser) return;
analyser.getFloatTimeDomainData(timeData);
var m = 0, i;
for (i = 0; i < timeData.length; i++) { var v = Math.abs(timeData[i]); if (v > m) m = v; }
if (m > runningMax) runningMax = m;
var cssW = canvas.width / dpr, cssH = canvas.height / dpr;
var step = BAR + GAP;
var maxBars = Math.max(1, Math.floor(cssW / step));
var interval = (winSec * 1000) / maxBars;
if (!lastCommit) lastCommit = ts;
if (ts - lastCommit >= interval) {
lastCommit = ts;
peaks.push(runningMax);
runningMax = 0;
while (peaks.length > maxBars) peaks.shift();
}
g.clearRect(0, 0, cssW, cssH);
g.fillStyle = color;
var mid = cssH / 2;
for (i = 0; i < peaks.length; i++) {
var x = cssW - (peaks.length - i) * step;
var h = Math.max(2, peaks[i] * cssH * 0.95);
g.fillRect(x, mid - h / 2, BAR, h);
}
}
function start() {
if (!ensureGraph()) return;
if (audioCtx.state === 'suspended' && audioCtx.resume) audioCtx.resume();
if (canvas.hidden) { canvas.hidden = false; size(); }
if (!running) { running = true; lastCommit = 0; rafId = requestAnimationFrame(draw); }
}
function stop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
window.addEventListener('resize', function () { if (!canvas.hidden) size(); });
return { start: start, stop: stop };
})();
if (audio) {
audio.addEventListener('play', function () { waveform.start(); });
audio.addEventListener('pause', function () { waveform.stop(); });
}
/* --------------------------------------------------- background tinting */
function luminance(r, g, b) { return 0.299 * r + 0.587 * g + 0.114 * b; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+38
View File
@@ -226,6 +226,19 @@ if (!defined('RADIO_CONFIG_LOADED')) {
'dynamic_meta' => radio_env_bool('DYNAMIC_META', true),
],
// ---- Waveform visualizer --------------------------------------
// Real-time scrolling waveform (Web Audio) shown after the listener
// presses play. Off by default. It routes playback through Web Audio,
// so a CROSS-ORIGIN stream must send CORS headers (see WAVEFORM_STREAM_CORS)
// or it is auto-disabled to protect audio.
'waveform' => [
'enabled' => radio_env_bool('WAVEFORM_ENABLED', false),
'height' => radio_env_int('WAVEFORM_HEIGHT', 72, 16),
'color' => radio_env('WAVEFORM_COLOR', ''), // empty => theme accent
'window' => radio_env_int('WAVEFORM_WINDOW_SECONDS', 30, 4),
'cors_ok' => radio_env_bool('WAVEFORM_STREAM_CORS', false),
],
// ---- Listeners / extras ---------------------------------------
'features' => [
'show_listeners' => radio_env_bool('SHOW_LISTENERS', true),
@@ -274,6 +287,25 @@ function e($str) {
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Is the stream on the same origin as the site? (relative/unknown => yes) */
function radio_stream_same_origin() {
$streamHost = parse_url(cfg('stream.url'), PHP_URL_HOST);
if ($streamHost === null || $streamHost === false || $streamHost === '') return true;
$siteHost = parse_url(cfg('site.url'), PHP_URL_HOST);
if ($siteHost === null || $siteHost === false || $siteHost === '') return true;
return strcasecmp($streamHost, $siteHost) === 0;
}
/**
* Whether the waveform visualizer may run: enabled AND the stream is either
* same-origin or the operator has confirmed the (cross-origin) stream sends
* CORS headers. Routing cross-origin audio through Web Audio without CORS
* silences it, so we auto-disable rather than risk breaking playback.
*/
function radio_waveform_active() {
return cfg('waveform.enabled') && (radio_stream_same_origin() || cfg('waveform.cors_ok'));
}
/**
* The subset of config that is safe and useful to expose to the browser.
* Emitted as `window.RADIO_CONFIG` by the page.
@@ -301,5 +333,11 @@ function radio_client_config() {
'offlineName' => cfg('offline.name'),
'offlineMessage' => cfg('offline.message'),
'offlineLoop' => cfg('offline.loop'),
'waveform' => [
'active' => radio_waveform_active(),
'height' => cfg('waveform.height'),
'color' => cfg('waveform.color'),
'window' => cfg('waveform.window'),
],
];
}
+6 -1
View File
@@ -11,6 +11,7 @@ $showHistory = cfg('features.show_history');
$showListeners = cfg('features.show_listeners');
$rich = cfg('player.style') !== 'simple';
$hasStream = cfg('stream.url') !== '';
$waveActive = radio_waveform_active();
?>
<!DOCTYPE html>
<html lang="<?= e(cfg('site.lang')) ?>" dir="ltr" prefix="og: http://ogp.me/ns#">
@@ -31,13 +32,17 @@ $hasStream = cfg('stream.url') !== '';
<div class="track-album" id="track-album"></div>
</div>
<audio id="stream" preload="none"<?= $rich ? '' : ' controls' ?><?= cfg('player.autoplay') ? ' autoplay' : '' ?>>
<audio id="stream" preload="none"<?= $waveActive ? ' crossorigin="anonymous"' : '' ?><?= $rich ? '' : ' controls' ?><?= cfg('player.autoplay') ? ' autoplay' : '' ?>>
<?php if ($hasStream): ?>
<source src="<?= e(cfg('stream.url')) ?>" type="<?= e(cfg('stream.type')) ?>"/>
<?php endif; ?>
Your browser does not support the audio element.
</audio>
<?php if ($waveActive): ?>
<canvas id="waveform" class="waveform" style="height:<?= (int) cfg('waveform.height') ?>px" hidden aria-hidden="true"></canvas>
<?php endif; ?>
<?php if ($rich): ?>
<div class="controls">
<button id="playpause" class="playpause" type="button" aria-label="Play"></button>