Skip to content

Commit ba4d8f4

Browse files
author
DatanoiseTV
committed
feat: video listener fix, AAC-in-TS for HLS, README overhaul
Raw /mount/video listeners now seek back to the most recent keyframe and prepend the cached SPS/PPS Annex-B NALUs before any live bytes. mpv / ffmpeg stop spamming "non-existing PPS 0 referenced" / "reference picture missing during reorder" because the listener joins on a clean IDR boundary instead of mid-GOP. Stream gains VideoInfo() accessor so the server package can read the video header state without touching the unexported stream mutex. AAC audio in HLS: - New BuildADTSHeader helper in codec_aac.go. - RTMP ingest parses the AAC AudioSpecificConfig on the first sequence-header packet, extracts profile / sampleRateIdx / channelConfig, and wraps every raw AAC frame in ADTS before broadcast. - MuxAVSegment takes an audioStreamType param; writePMTAV emits stream_type 0x03 (MP3) or 0x0F (ADTS-AAC) accordingly. - HLSOutput picks the stream type from the audio track's content type so AAC sources carrying H.264 now produce a playable A/V HLS stream end-to-end. README overhauled: tagline switches to "audio + video", the old "What's new in v2.0 Beta" list is replaced by a v2.0.0-beta.5 section covering the video pipeline + the audio / auth / ops fixes from the last few weeks, and a new "Streaming video from OBS" section walks through the full OBS + HLS flow.
1 parent 78ea438 commit ba4d8f4

7 files changed

Lines changed: 305 additions & 32 deletions

File tree

README.md

Lines changed: 135 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
![TinyIce Logo](https://raw.githubusercontent.com/DatanoiseTV/tinyice/main/assets/logo.png?v=2)
44

5-
**One binary. Pure audio.**
5+
**One binary. Audio + video.**
66

7-
> High-performance Icecast-compatible streaming with WebRTC, AutoDJ, transcoding, and a world-class web interface. Deploy anywhere in seconds.
7+
> High-performance Icecast-compatible streaming server with RTMP/SRT/WebRTC ingest, AutoDJ, live audio transcoding, HLS audio/video output, and a built-in admin SPA. Deploy anywhere in seconds.
88
99
### Landing Page
1010
![Landing Page](assets/screenshots/landing.png)
@@ -22,24 +22,91 @@
2222
[![Go Report Card](https://goreportcard.com/badge/github.com/DatanoiseTV/tinyice)](https://goreportcard.com/report/github.com/DatanoiseTV/tinyice)
2323
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
2424

25-
## What's New in v2.0 Beta
26-
27-
- **Complete Admin UI Rewrite** — Modern single-page app built with Preact, real-time SSE updates, dark theme
28-
- **Bearer Token API Auth** — Create API access tokens for scripts and integrations, with expiry and usage tracking
29-
- **Full Branding System** — Custom site name, tagline, logo upload, accent color picker, Markdown landing page
30-
- **Interactive API Docs** — Built-in Swagger UI at `/api/docs` with complete OpenAPI 3.0 spec
31-
- **WebRTC Go Live** — Broadcast from your browser with audio device selection, spectrum analyzer, level meters with headroom (dB)
32-
- **AutoDJ Studio** — 3-column studio interface with library browser, transport controls, visualizer, playlist editor, and mount selector
33-
- **AutoDJ Editing** — Edit existing AutoDJ instances directly (name, mount, format, bitrate, etc.)
34-
- **Dashboard Improvements** — Split inbound/outbound bandwidth stats, real-time stream health
35-
- **Stream Management** — Configured-but-offline mounts now visible, proper create/delete/kick workflows
36-
- **Relay & Transcoder Management** — Full CRUD with live status indicators
37-
- **Markdown Landing Page** — Full GFM support via `marked` — headings, lists, code blocks, links, images
38-
- **Color Picker** — Visual accent color selection with 10 presets + native OS color picker + hex input
39-
- **Logo Upload** — PNG/JPG/SVG logo served at `/branding/logo`, shown in nav bar
40-
- **No CSRF for API** — JSON API requests no longer need CSRF tokens
41-
- **Makefile + go:generate**`make build` rebuilds everything; frontend builds automatically via `go generate`
42-
- **Multi-auth** — Session cookies, Bearer tokens, Basic Auth, Passkeys (WebAuthn), OIDC/OAuth2
25+
## What's New in v2.0.0-beta.5 — Video
26+
27+
This release turns TinyIce from an audio-only streaming server into an
28+
A/V one, and does a security / correctness pass on top.
29+
30+
### Video streaming
31+
32+
- **RTMP with H.264 video** — OBS / FFmpeg / any RTMP publisher can push
33+
H.264 + MP3 or H.264 + AAC into a `/mount`. The RTMP app name is
34+
honoured as the tinyice mount and the stream key as the source
35+
password, matching the UX OBS expects.
36+
- **SRT video ingest** — MPEG-TS A/V publishers land in the same
37+
`/mount` + `/mount/video` layout as RTMP.
38+
- **HLS audio + video output** — every mount exposes
39+
`/mount/playlist.m3u8`; when a `/mount/video` sub-mount exists the
40+
segments are muxed A/V. PCR is emitted; `TARGETDURATION` is tight;
41+
`EXTINF` / PTS advance by the configured segment duration. MP3 and
42+
AAC audio codecs are both supported (AAC is ADTS-wrapped and the
43+
PMT `stream_type` switches to `0x0F`).
44+
- **Keyframe-aware raw video listeners** — a direct
45+
`GET /mount/video` seeks back to the latest IDR and prepends the
46+
cached SPS/PPS so `mpv http://host/mount/video` plays immediately.
47+
- **Browser player** — the in-page Player automatically switches from
48+
`<audio>` to `<video>` when the mount has video. Safari / iOS play
49+
HLS natively; Chromium / Firefox get `hls.js` dynamically loaded.
50+
51+
### Audio correctness
52+
53+
- Pure-Go multi-codec decode for the transcoder + AutoDJ — MP3, Ogg
54+
Opus, Ogg Vorbis, FLAC, FLAC-in-Ogg, WAV (8/16/24/32-bit PCM + IEEE
55+
float, mono/stereo).
56+
- Automatic resampler so **MP3 / Vorbis / FLAC / WAV → Opus** plays at
57+
the right speed (the Opus encoder is locked at 48 kHz).
58+
- **MP3 bitrate** is actually honoured (the bundled `shine` encoder
59+
has its bitrate index / slots-per-frame reached into explicitly).
60+
- **Ogg page rewriter per listener** — BOS / Tags / granule are
61+
regenerated so late joiners don't see the minutes-long granule jump
62+
that strict decoders fill with silence (the "robotic voice" bug).
63+
- **Icecast SOURCE** captures the initial Ogg BOS + setup pages so
64+
new listeners get a playable start.
65+
66+
### Operations & security
67+
68+
- OIDC state is session-bound, nonce-validated, and rejects OIDC
69+
accounts whose email isn't verified. GitHub `/user/emails` is
70+
consulted; only primary+verified addresses log in.
71+
- Sessions have absolute and sliding expiry with a periodic reaper;
72+
login rotates the cookie (no session fixation); deleted users have
73+
their active sessions purged.
74+
- Login timing is constant — always runs bcrypt so unknown usernames
75+
don't return faster than known ones.
76+
- `TrustedProxies` config + `X-Forwarded-For` handling so scan
77+
detection / bans work behind nginx / Caddy / Traefik without
78+
auto-whitelisting loopback.
79+
- Auto-updater verifies SHA-256 from `checksums.txt` before
80+
overwriting the running binary.
81+
- RTMP shutdown closes live publisher connections so `Ctrl+C` quits
82+
within seconds even mid-stream.
83+
- CSRF on every mutating admin form; super-admin gates on transcoder
84+
+ webhook CRUD; webhook / relay URLs reject loopback / RFC1918
85+
targets (SSRF).
86+
- `SaveConfig` is serialised across goroutines so concurrent admin
87+
writes can't shred the JSON.
88+
- Auto-remove dormant streams after 2 min of silence (was defined
89+
but never enabled).
90+
- YP directory reporter emits proper `add` / `touch` / `remove`
91+
lifecycle instead of repeated `add`s.
92+
93+
### Admin UI
94+
95+
- **Edit** for Streams, Transcoders, Relays, AutoDJ (in-place updates,
96+
no more destroy-and-recreate on edit).
97+
- Transcoder editor surfaces Opus application / frame size / complexity
98+
/ VBR, plus a custom sample rate override.
99+
- Landing markdown is DOMPurified before it hits the DOM (was an
100+
admin → visitor XSS).
101+
- Error toasts on every mutation path so 403 / 500 don't silently
102+
disappear.
103+
- SSE reconnect no longer duplicates event delivery.
104+
105+
### Build / packaging
106+
107+
- Multi-stage `Dockerfile` (+ `.dockerignore`) for container deploys.
108+
- `make build` rebuilds frontend + binary; `go generate ./server/...`
109+
pulls in hls.js + dompurify as lazy chunks.
43110

44111
## Why TinyIce?
45112

@@ -60,15 +127,17 @@ Traditional streaming servers can be complex to configure and resource-heavy. Ti
60127

61128
### Streaming & Protocols
62129
- **Icecast2 Compatible**: Works with standard source clients (BUTT, OBS, Mixxx, LadioCast) and players (VLC, web browsers).
130+
- **RTMP Ingest**: OBS / ffmpeg / any RTMP publisher can push H.264 + MP3 or H.264 + AAC. OBS's Server-path = mount, Stream-key = password UX just works.
131+
- **SRT Ingest**: Low-latency SRT with MPEG-TS demux for both audio and video.
63132
- **WebRTC Source & Playback**: Ultra-low-latency browser-based broadcasting and listening via the Go Live page.
133+
- **HLS A/V Output**: `/mount/playlist.m3u8` serves audio-only or audio+video segments depending on what the source pushes. PMT advertises MP3 or ADTS-AAC correctly; PCR is emitted; SPS/PPS are injected on every keyframe so late joiners can decode.
64134
- **High-Performance Distribution**: Shared circular buffer architecture designed for 100,000+ concurrent listeners per stream.
65135
- **Instant Start**: Listeners receive a 64KB audio burst upon connection, eliminating the "buffering" delay.
66-
- **Built-in Transcoding**: Pure Go transcoding (MP3/Opus) to provide multiple quality options from a single source. No FFmpeg required.
136+
- **Multi-Codec Transcoding**: Pure-Go transcoder and AutoDJ accept MP3 / Ogg Opus / Ogg Vorbis / FLAC / FLAC-in-Ogg / WAV as input and re-encode to MP3 or Opus with automatic resampling. No FFmpeg required.
67137
- **Edge Relaying**: Pull streams from upstream servers with automatic reconnection.
68138
- **Smart Fallback & Auto-Recovery**: Automatically switch listeners to a backup stream if the primary drops.
69139
- **Outbound ICY Metadata**: Injects song titles into the audio stream for traditional radio players.
70140
- **Playlist Support**: `.m3u8`, `.m3u`, and `.pls` playlists for VLC, Winamp, mobile apps.
71-
- **HLS Output**: Automatic HLS segmentation for each mount point.
72141

73142
### AutoDJ
74143
- **Multi-Instance Orchestration**: Multiple independent AutoDJs on different mounts from a single server.
@@ -172,6 +241,50 @@ Point your encoder (BUTT, OBS, Mixxx) to:
172241

173242
Or use the **Go Live** page in the admin panel to broadcast directly from your browser via WebRTC.
174243

244+
## Streaming video from OBS
245+
246+
TinyIce accepts H.264 + AAC (or H.264 + MP3) over RTMP and produces
247+
HLS audio+video at `/<mount>/playlist.m3u8`.
248+
249+
1. **Enable RTMP** in `tinyice.json`:
250+
251+
```json
252+
"ingest": {
253+
"rtmp_enabled": true,
254+
"rtmp_port": "1935"
255+
}
256+
```
257+
258+
2. **Create a mount** in the admin UI (Streams → Add Mount). Give it
259+
a password — that password becomes your OBS Stream Key.
260+
261+
3. **OBS → Settings → Stream** (Service: Custom):
262+
263+
- **Server**: `rtmp://<your-host>/<mount>` — e.g. `rtmp://radio.example.com/live`
264+
- **Stream Key**: your mount's source password
265+
266+
(The classic layout `rtmp://<host>/` + Stream Key `mount?key=password`
267+
also works.)
268+
269+
4. **OBS → Settings → Output** — set Video Encoder to `x264` (or a
270+
hardware H.264 encoder) and Audio Encoder to AAC (default) or
271+
MP3. 2-second keyframe interval is a good default for HLS latency.
272+
273+
5. **Click Start Streaming.** The server log will show
274+
`RTMP: Publishing started mount=/live` followed by
275+
`RTMP: Parsed AVC config` and `RTMP: Parsed AAC ASC`.
276+
277+
6. **Watch it** three ways:
278+
279+
- **Built-in player**: `https://<your-host>/player/<mount>`. If the
280+
source has video, the player renders an HTML5 `<video>`; Safari
281+
plays HLS natively, other browsers get `hls.js` loaded on
282+
demand.
283+
- **Direct HLS**: `https://<your-host>/<mount>/playlist.m3u8`
284+
works in VLC, mpv, ffplay, and any HLS-capable client.
285+
- **Raw video**: `http://<your-host>/<mount>/video` plays the
286+
H.264 Annex-B bytes directly (useful for debugging with mpv).
287+
175288
## API Usage
176289

177290
### Authentication

relay/codec_aac.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ func ParseADTSFrames(data []byte) ([]ADTSFrame, []byte) {
8585
return frames, remaining
8686
}
8787

88+
// BuildADTSHeader builds a 7-byte ADTS header for a raw AAC frame of
89+
// `payloadLen` bytes. Used to wrap raw AAC frames coming out of RTMP
90+
// (which carries AAC without ADTS) so the MPEG-TS muxer can declare
91+
// stream_type=0x0F (ADTS-AAC) in the PMT and downstream decoders
92+
// (ffmpeg, Safari, hls.js) can parse the audio.
93+
//
94+
// profile — AAC profile from the AudioSpecificConfig
95+
// (0=Main, 1=LC, 2=SSR, 3=LTP)
96+
// sampleRateIdx — index into the AAC sample-rate table (0–12)
97+
// channelConfig — AAC channel configuration (1=mono, 2=stereo, …)
98+
func BuildADTSHeader(profile, sampleRateIdx, channelConfig byte, payloadLen int) []byte {
99+
frameLen := payloadLen + 7
100+
h := make([]byte, 7)
101+
h[0] = 0xFF
102+
// 0b11110001 — sync continued, MPEG-4 version (ID=0), layer=00,
103+
// protection_absent=1 (no CRC).
104+
h[1] = 0xF1
105+
h[2] = (profile << 6) | ((sampleRateIdx & 0x0F) << 2) | ((channelConfig >> 2) & 0x01)
106+
h[3] = ((channelConfig & 0x03) << 6) | byte((frameLen>>11)&0x03)
107+
h[4] = byte((frameLen >> 3) & 0xFF)
108+
// bottom 3 bits of frame_length plus top 5 bits of buffer_fullness
109+
// (0x7FF VBR sentinel → top five bits are all 1).
110+
h[5] = byte(((frameLen & 0x07) << 5) | 0x1F)
111+
// Low 6 bits of buffer_fullness (0x3F) + num_raw_data_blocks (00).
112+
h[6] = 0xFC
113+
return h
114+
}
115+
88116
// BuildAudioSpecificConfig creates the 2-byte AudioSpecificConfig
89117
// used in MP4/fMP4 init segments for AAC-LC.
90118
func BuildAudioSpecificConfig(profile, sampleRateIdx, channelConfig byte) []byte {

relay/ingest_rtmp.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ type rtmpHandler struct {
171171
sps []byte // cached SPS NALU
172172
pps []byte // cached PPS NALU
173173
naluLenSize int // AVCC NALU length size (usually 4)
174+
175+
// AAC state parsed from the AudioSpecificConfig (first AAC
176+
// SequenceHeader received). Used to wrap raw AAC frames in ADTS
177+
// headers before broadcasting so downstream TS muxing / HLS works.
178+
aacProfile byte
179+
aacSampleRateIdx byte
180+
aacChannelConfig byte
181+
aacConfigReady bool
174182
}
175183

176184
// OnServe is called when the connection is established.
@@ -287,10 +295,42 @@ func (h *rtmpHandler) OnAudio(timestamp uint32, payload io.Reader) error {
287295
h.stream.ContentType = "audio/aac"
288296
h.stream.mu.Unlock()
289297
}
290-
// Skip AAC sequence header (AudioSpecificConfig), only pass raw frames
291298
if audioTag.AACPacketType == flvtag.AACPacketTypeSequenceHeader {
299+
// Parse AudioSpecificConfig so we can ADTS-wrap later frames.
300+
// ASC layout (ISO 14496-3):
301+
// 5 bits objectType, 4 bits samplingFreqIdx,
302+
// 4 bits channelConfiguration, 3 bits GASpecificConfig.
303+
if len(data) >= 2 {
304+
objType := (data[0] >> 3) & 0x1F
305+
sri := ((data[0] & 0x07) << 1) | ((data[1] >> 7) & 0x01)
306+
ch := (data[1] >> 3) & 0x0F
307+
h.aacProfile = 0
308+
if objType > 0 {
309+
h.aacProfile = objType - 1 // ADTS profile = objectType - 1
310+
}
311+
h.aacSampleRateIdx = sri
312+
h.aacChannelConfig = ch
313+
h.aacConfigReady = true
314+
logger.L.Infow("RTMP: Parsed AAC ASC",
315+
"mount", h.mount,
316+
"profile", h.aacProfile,
317+
"sr_idx", h.aacSampleRateIdx,
318+
"ch", h.aacChannelConfig,
319+
)
320+
}
292321
return nil
293322
}
323+
// Raw AAC payload — prepend ADTS so the MPEG-TS muxer / HLS
324+
// clients can actually decode it. Without this the bytes leave
325+
// the server as "naked" AAC and any container-based consumer
326+
// fails silently.
327+
if h.aacConfigReady {
328+
hdr := BuildADTSHeader(h.aacProfile, h.aacSampleRateIdx, h.aacChannelConfig, len(data))
329+
wrapped := make([]byte, 0, len(hdr)+len(data))
330+
wrapped = append(wrapped, hdr...)
331+
wrapped = append(wrapped, data...)
332+
data = wrapped
333+
}
294334
default:
295335
// Unknown format, pass through
296336
}
@@ -397,6 +437,23 @@ func (h *rtmpHandler) parseAVCConfig(data []byte) {
397437
}
398438
}
399439

440+
// Publish SPS+PPS as Annex-B on the video stream so a listener that
441+
// subscribes via HTTP gets them prepended by the listener handler.
442+
// Each NALU is preceded by the 4-byte 00 00 00 01 start code.
443+
if h.videoStream != nil && (len(h.sps) > 0 || len(h.pps) > 0) {
444+
sc := []byte{0x00, 0x00, 0x00, 0x01}
445+
out := make([]byte, 0, len(h.sps)+len(h.pps)+8)
446+
if len(h.sps) > 0 {
447+
out = append(out, sc...)
448+
out = append(out, h.sps...)
449+
}
450+
if len(h.pps) > 0 {
451+
out = append(out, sc...)
452+
out = append(out, h.pps...)
453+
}
454+
h.videoStream.StoreVideoHeaders(out)
455+
}
456+
400457
logger.L.Infow("RTMP: Parsed AVC config",
401458
"mount", h.mount,
402459
"sps_len", len(h.sps),

relay/mux_mpegts.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,30 @@ func (m *TSMuxer) MuxMP3Segment(mp3Data []byte, pts int64) []byte {
4747
return buf.Bytes()
4848
}
4949

50+
// Audio stream_type values used in the MPEG-TS PMT.
51+
const (
52+
audioStreamTypeMP3 byte = 0x03
53+
audioStreamTypeAAC byte = 0x0F // ADTS-wrapped AAC
54+
)
55+
5056
// MuxAVSegment wraps audio and video data into a complete MPEG-TS segment.
51-
// audioData is MP3 audio, videoData is H.264 Annex B data.
52-
// audioPTS and videoPTS are presentation timestamps in 90kHz units.
53-
func (m *TSMuxer) MuxAVSegment(audioData []byte, videoData []byte, audioPTS, videoPTS int64) []byte {
57+
// audioData is MP3 or ADTS-AAC (pick via audioStreamType); videoData is
58+
// H.264 Annex-B. audioPTS and videoPTS are 90 kHz PTS values.
59+
//
60+
// The PES stream_id field stays mp3StreamID for MP3 and switches to
61+
// 0xC0 for AAC as well (both are "audio stream 1" in ISO 13818-1 terms;
62+
// the stream_type in the PMT is what actually distinguishes them).
63+
func (m *TSMuxer) MuxAVSegment(audioData []byte, videoData []byte, audioPTS, videoPTS int64, audioStreamType byte) []byte {
64+
if audioStreamType == 0 {
65+
audioStreamType = audioStreamTypeMP3
66+
}
5467
var buf bytes.Buffer
5568

5669
// Write PAT
5770
m.writePAT(&buf)
5871

59-
// Write PMT (updated for A/V)
60-
m.writePMTAV(&buf)
72+
// Write PMT (updated for A/V, with the correct audio stream_type)
73+
m.writePMTAV(&buf, audioStreamType)
6174

6275
// Write video PES first (usually larger, keyframe-aligned)
6376
if len(videoData) > 0 {
@@ -72,7 +85,7 @@ func (m *TSMuxer) MuxAVSegment(audioData []byte, videoData []byte, audioPTS, vid
7285
return buf.Bytes()
7386
}
7487

75-
func (m *TSMuxer) writePMTAV(buf *bytes.Buffer) {
88+
func (m *TSMuxer) writePMTAV(buf *bytes.Buffer, audioStreamType byte) {
7689
packet := make([]byte, tsPacketSize)
7790

7891
packet[0] = tsSyncByte
@@ -104,8 +117,8 @@ func (m *TSMuxer) writePMTAV(buf *bytes.Buffer) {
104117
pmt[15] = 0xF0
105118
pmt[16] = 0x00
106119

107-
// Audio stream: MP3
108-
pmt[17] = 0x03 // stream_type = 0x03 (MP3)
120+
// Audio stream: MP3 (0x03) or ADTS AAC (0x0F) — chosen by caller.
121+
pmt[17] = audioStreamType
109122
pmt[18] = 0xE0 | byte(audioPID>>8)
110123
pmt[19] = byte(audioPID & 0xFF)
111124
pmt[20] = 0xF0

0 commit comments

Comments
 (0)