Files
scriptosandClaude Opus 5.5 67a687f4e6
Build & Push Docker Image / Build & push image (push) Successful in 6m49s
Release 0.7.1: gapless conversion only without quality loss
LOSSLESS_ONLY (default true): follow-up tracks are only converted when
nothing is lost (MP3 into a FLAC stream). FLAC after MP3 or higher
resolution FLAC plays in original quality with a normal track change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:18:08 +01:00

521 lines
20 KiB
Python

"""Gapless playback for bluos-upnp-bridge.
BluOS restarts its pipeline for every URL, which leaves a 1-2 s gap between tracks. In gapless
mode the bridge serves one continuous stream and appends the next track's audio data directly
after the current one:
- MP3: ID3v2/ID3v1 tags and the Xing/Info frame of follow-up tracks are dropped, the MPEG frames
are simply concatenated (a few ms of encoder padding remain).
- FLAC: the first track's STREAMINFO gets "total samples unknown" and a zero MD5 so decoders keep
going; follow-up tracks are appended without their metadata blocks. Tracks must have the same
sample rate, channel count and bit depth.
Tracks that can't be stitched (different codec or FLAC parameters) end the stream; the bridge then
falls back to a normal track change.
"""
import logging
import shutil
import subprocess
import threading
import time
import urllib.request
log = logging.getLogger("gapless")
CHUNK = 65536
# ---------------------------------------------------------------------------- MP3
_MP3_BITRATES = {
# (version_is_mpeg1, layer3) -> kbit/s table (index 0..15)
True: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
False: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
}
_MP3_RATES = {3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000]}
def id3v2_size(buf):
"""Length of a leading ID3v2 tag (0 if none)."""
if len(buf) >= 10 and buf[:3] == b"ID3":
size = (buf[6] & 0x7F) << 21 | (buf[7] & 0x7F) << 14 | (buf[8] & 0x7F) << 7 | (buf[9] & 0x7F)
return 10 + size + (10 if buf[5] & 0x10 else 0)
return 0
def mp3_frame_len(buf, pos):
"""Length of the MPEG layer III frame at pos, or 0 if no valid header."""
if pos + 4 > len(buf) or buf[pos] != 0xFF or (buf[pos + 1] & 0xE0) != 0xE0:
return 0
version = (buf[pos + 1] >> 3) & 0x03 # 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5
layer = (buf[pos + 1] >> 1) & 0x03 # 1 = layer III
br_idx = buf[pos + 2] >> 4
sr_idx = (buf[pos + 2] >> 2) & 0x03
padding = (buf[pos + 2] >> 1) & 0x01
if version == 1 or layer != 1 or br_idx in (0, 15) or sr_idx == 3:
return 0
bitrate = _MP3_BITRATES[version == 3][br_idx] * 1000
rate = _MP3_RATES[version][sr_idx]
factor = 144 if version == 3 else 72
return factor * bitrate // rate + padding
def mp3_sync(buf, start=0):
"""Offset of the first frame at/after start that is followed by another valid frame."""
pos = start
while pos < len(buf) - 4:
n = mp3_frame_len(buf, pos)
if n and (pos + n + 4 > len(buf) or mp3_frame_len(buf, pos + n)):
return pos
pos += 1
return -1
def mp3_skip_info_frame(buf, pos):
"""If the frame at pos is a Xing/Info/VBRI header frame, return the offset after it."""
n = mp3_frame_len(buf, pos)
if n and (b"Xing" in buf[pos:pos + 64] or b"Info" in buf[pos:pos + 64] or b"VBRI" in buf[pos:pos + 64]):
return pos + n
return pos
# ---------------------------------------------------------------------------- FLAC
def flac_header_len(buf):
"""Length of 'fLaC' + all metadata blocks, or -1 if buf is too short, or 0 if not FLAC."""
if buf[:4] != b"fLaC":
return 0
pos = 4
while True:
if pos + 4 > len(buf):
return -1
last = buf[pos] & 0x80
length = int.from_bytes(buf[pos + 1:pos + 4], "big")
pos += 4 + length
if last:
return pos if pos <= len(buf) else -1
def flac_params(header):
"""Sample rate / channels / bits per sample bytes of STREAMINFO for compatibility checks."""
si = header[8:8 + 34]
return bytes(si[10:13]) + bytes([si[13] & 0xF0])
def flac_format(params):
"""(sample rate, channels, bits per sample) from flac_params()."""
b = params
rate = (b[0] << 12) | (b[1] << 4) | (b[2] >> 4)
channels = ((b[2] >> 1) & 0x07) + 1
bits = (((b[2] & 0x01) << 4) | (b[3] >> 4)) + 1
return rate, channels, bits
def mp3_rate(buf, pos):
version = (buf[pos + 1] >> 3) & 0x03
return _MP3_RATES[version][(buf[pos + 2] >> 2) & 0x03]
FFMPEG = shutil.which("ffmpeg")
LOSSLESS_ONLY = True # only convert follow-up tracks if no quality is lost (set by the bridge)
def _lossless_conversion(src_kind, dst_kind, src_format, dst_params):
"""True if converting src into the stream format keeps the full quality.
MP3 -> FLAC only re-packs the decoded audio. FLAC -> FLAC is fine if sample rate and bit depth don't
go down (and the channels match). FLAC -> MP3 or downsampling would lose quality.
"""
if src_kind == "mp3" and dst_kind == "flac":
return True
if src_kind == "flac" and dst_kind == "flac" and src_format and dst_params:
rate, channels, bits = src_format
d_rate, d_channels, d_bits = flac_format(dst_params)
return channels == d_channels and d_rate >= rate and d_bits >= bits
return False
class Transcoder:
"""ffmpeg converting a follow-up track into the session's codec (file-like: read/close)."""
def __init__(self, url, kind, rate, channels=2, bits=16):
cmd = [FFMPEG, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", url, "-map", "0:a:0",
"-ar", str(rate), "-ac", str(channels)]
if kind == "flac":
fmt = {16: "s16", 24: "s32", 32: "s32"}.get(bits, "s16")
cmd += ["-c:a", "flac", "-sample_fmt", fmt]
if bits == 24:
cmd += ["-bits_per_raw_sample", "24"]
cmd += ["-f", "flac", "-"]
else:
cmd += ["-c:a", "libmp3lame", "-b:a", "320k", "-write_xing", "0", "-id3v2_version", "0",
"-f", "mp3", "-"]
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
def read(self, n=CHUNK):
return self.proc.stdout.read(n)
def close(self):
if self.proc.poll() is None:
self.proc.kill()
self.proc.stdout.close()
self.proc.wait()
def flac_open_ended(header):
"""STREAMINFO with total samples = 0 (unknown) and MD5 = 0, so decoders don't stop early."""
h = bytearray(header)
si = 8 # 'fLaC' + block header
# block/frame sizes "any": follow-up tracks (or ffmpeg conversions) may use other block sizes
h[si:si + 4] = (16).to_bytes(2, "big") + (65535).to_bytes(2, "big")
h[si + 4:si + 10] = b"\x00" * 6
h[si + 13] &= 0xF0
h[si + 14:si + 18] = b"\x00\x00\x00\x00"
h[si + 18:si + 34] = b"\x00" * 16
return bytes(h)
def flac_sync(buf, start=0):
"""Offset of the next plausible FLAC frame header (sync 0xFFF8/0xFFF9)."""
pos = start
while pos < len(buf) - 4:
if buf[pos] == 0xFF and buf[pos + 1] in (0xF8, 0xF9):
bs, sr = buf[pos + 2] >> 4, buf[pos + 2] & 0x0F
ch, ss = buf[pos + 3] >> 4, (buf[pos + 3] >> 1) & 0x07
if bs != 0 and sr != 15 and ch < 11 and ss not in (3, 7):
return pos
pos += 1
return -1
# ---------------------------------------------------------------------------- session
class Segment:
def __init__(self, upstream, uri, meta, info, label, cover=""):
self.upstream = upstream
self.cover = cover
self.uri = uri
self.meta = meta
self.info = info
self.label = label
self.streamed = False # proxy started sending its audio
@property
def duration(self):
return self.info.get("duration") or 0
class Session:
"""One continuous stream made of several tracks."""
def __init__(self, first, start_secs=0, lead_secs=20):
self.segments = [first]
self.start_secs = start_secs # stream begins this far into the first track
self.lead_secs = lead_secs # follow-up tracks are appended this long before they're due
self.cond = threading.Condition()
self.closed = False
self.ended = False # proxy finished writing (no more segments)
self.served = 0 # number of player connections served
self.generation = 0 # bumped by rebase(); writers of older generations stop
self.mp3_rate = 44100 # sample rate of the first MP3 track (target for conversions)
self.playback_secs = 0 # updated by the renderer from BluOS' counter
def starts(self):
"""Playback time (stream seconds) at which each segment starts."""
out, t = [], -self.start_secs
for seg in self.segments:
out.append(max(0, t))
t += seg.duration
return out
def end_of_last(self):
return sum(s.duration for s in self.segments) - self.start_secs
def end_of_streamed(self):
"""Playback time at which the audio sent so far runs out."""
sent = [s for i, s in enumerate(self.segments) if s.streamed or i == 0]
return sum(s.duration for s in sent) - self.start_secs
def hold_until_played(self, generation, margin=1):
"""Keep the connection open until playback reaches the end of the sent audio.
BluOS treats an EOF like a dropped radio stream and reconnects at once, so ending the
stream early would make it replay the session from the start.
"""
with self.cond:
while not self.closed and self.generation == generation and self.playback_secs < self.end_of_streamed() - margin:
self.cond.wait(1)
def _queued(self):
"""The follow-up segment that hasn't been sent to the player yet (or None)."""
if len(self.segments) >= 2 and not self.segments[-1].streamed:
return self.segments[-1]
return None
def offer_next(self, seg, current_index):
"""Queue seg after the playing track, replacing a queued one that wasn't sent yet.
Returns False if a different track was already sent to the player (too late to change).
"""
with self.cond:
queued = self._queued()
if queued is not None and len(self.segments) - 1 > current_index:
if queued.uri != seg.uri:
self.segments[-1] = seg
log.info("gapless: queued track changed to %r", seg.label)
elif len(self.segments) - 1 == current_index:
self.segments.append(seg)
elif self.segments[-1].uri != seg.uri:
return False
self.cond.notify_all()
return True
def clear_next(self, current_index):
"""Drop the queued follow-up track (queue emptied in the app). False if already sent."""
with self.cond:
if len(self.segments) - 1 <= current_index:
return True
if self._queued() is not None:
self.segments.pop()
log.info("gapless: queued track removed")
self.cond.notify_all()
return True
return False
def wait_segment(self, i, timeout_after_end=8):
"""Block until segment i exists and is due. None if the session ends.
Follow-up tracks are only sent lead_secs before they start, so queue changes in the app
until then still take effect.
"""
with self.cond:
while not self.closed:
if len(self.segments) > i:
if i == 0 or self.playback_secs >= self.starts()[i] - self.lead_secs:
return self.segments[i]
elif self.playback_secs >= self.end_of_last() + timeout_after_end:
# nothing queued: give up shortly after playback reaches the end of the stream
return None
self.cond.wait(1)
return None
def rebase(self, index, position):
"""Continue the session from segment `index` at `position` seconds (player reconnected).
BluOS drops paused "radio" streams after about two minutes and reconnects on resume with
its counter reset to 0 - without this it would hear the session from the very start.
"""
with self.cond:
self.segments = self.segments[index:]
for seg in self.segments:
seg.streamed = False
self.start_secs = max(0, int(position))
self.playback_secs = 0
self.ended = False
self.generation += 1
self.cond.notify_all()
def close(self):
with self.cond:
self.closed = True
self.cond.notify_all()
class IcyWriter:
"""Writes audio, inserting ICY metadata (StreamTitle) every metaint bytes if requested."""
def __init__(self, out, metaint=0):
self.out = out
self.metaint = metaint
self.count = 0
self.title = ""
self.cover = "" # sent as StreamUrl - some players show it as artwork
self.sent_title = None
def write(self, data):
if not self.metaint:
self.out.write(data)
return
while data:
room = self.metaint - self.count
part, data = data[:room], data[room:]
self.out.write(part)
self.count += len(part)
if self.count == self.metaint:
self.count = 0
if (self.title, self.cover) != self.sent_title:
text = f"StreamTitle='{self.title.replace(chr(39), '`')}';"
if self.cover:
text += f"StreamUrl='{self.cover}';"
text = text.encode("utf-8")[:4000]
blocks = (len(text) + 15) // 16
self.out.write(bytes([blocks]) + text.ljust(blocks * 16, b"\x00"))
self.sent_title = (self.title, self.cover)
else:
self.out.write(b"\x00")
def _open(url, start=0):
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-"})
return urllib.request.urlopen(req, timeout=20)
def _total_size(resp):
cr = resp.headers.get("Content-Range", "")
tail = cr.rsplit("/", 1)[-1] if cr else resp.headers.get("Content-Length", "")
return int(tail) if tail.isdigit() else 0
def _read_at_least(resp, buf, n):
while len(buf) < n:
chunk = resp.read(CHUNK)
if not chunk:
break
buf += chunk
return buf
def stream_session(session, writer, kind_hint=None):
"""Write all segments of the session into writer. Returns the number of segments sent."""
kind = None
params = None
sent = 0
i = 0
gen = session.generation
while True:
seg = session.wait_segment(i)
if seg is None or session.generation != gen:
break
start_secs = session.start_secs if i == 0 else 0
try:
resp = _open(seg.upstream)
except Exception as exc:
log.warning("segment %r: upstream failed: %s", seg.label, exc)
break
ctype = resp.headers.get("Content-Type", "")
seg_kind = "flac" if "flac" in ctype else "mp3" if ("mpeg" in ctype or "mp3" in ctype) else None
total = _total_size(resp)
buf = _read_at_least(resp, b"", 4096)
convert = False
src_format = None
if i > 0 and seg_kind == "flac" and kind == "flac":
while flac_header_len(buf) == -1:
more = resp.read(CHUNK)
if not more:
break
buf += more
hl = flac_header_len(buf)
convert = hl > 0 and flac_params(buf[:hl]) != params # different sample rate / bit depth
src_format = flac_format(flac_params(buf[:hl])) if hl > 0 else None
if i > 0 and (seg_kind != kind or convert) and LOSSLESS_ONLY and not _lossless_conversion(seg_kind, kind, src_format, params):
log.info("segment %r (%s) would lose quality as %s, ending gapless stream (normal track change)",
seg.label, ctype or "?", kind)
resp.close()
break
if i > 0 and (seg_kind != kind or convert):
if not FFMPEG:
log.info("segment %r (%s) can't be stitched to %s (no ffmpeg), ending gapless stream",
seg.label, ctype, kind)
resp.close()
break
resp.close()
rate, channels, bits = flac_format(params) if kind == "flac" else (session.mp3_rate, 2, 16)
log.info("gapless: converting %r (%s) to %s %s Hz", seg.label, ctype or "?", kind, rate)
resp = Transcoder(seg.upstream, kind, rate, channels, bits)
seg_kind, total = kind, 0
buf = _read_at_least(resp, b"", 4096)
if seg_kind == "mp3":
head = id3v2_size(buf)
buf = _read_at_least(resp, buf, head + 4096)
audio = mp3_sync(buf, head)
if audio < 0:
log.warning("segment %r: no MP3 frames found", seg.label)
resp.close()
break
if i == 0:
session.mp3_rate = mp3_rate(buf, audio)
if i > 0 or start_secs:
audio = mp3_skip_info_frame(buf, audio)
if start_secs and seg.duration and total:
# jump into the file: approximate byte position, then resync to a frame
target = audio + int((total - audio) * start_secs / seg.duration)
resp.close()
resp = _open(seg.upstream, target)
buf = _read_at_least(resp, b"", 8192)
pos = mp3_sync(buf, 0)
buf = buf[pos:] if pos >= 0 else b""
else:
buf = buf[audio:] if i > 0 else buf # first track: keep its tags for the decoder
else: # flac
hlen = flac_header_len(buf)
while hlen == -1:
more = resp.read(CHUNK)
if not more:
break
buf += more
hlen = flac_header_len(buf)
if hlen <= 0:
log.warning("segment %r: invalid FLAC header", seg.label)
resp.close()
break
header = buf[:hlen]
p = flac_params(header)
if params and p != params:
log.info("segment %r: different FLAC format, ending gapless stream", seg.label)
resp.close()
break
params = params or p
if i == 0:
writer.write(flac_open_ended(header))
if start_secs and seg.duration and total:
target = hlen + int((total - hlen) * start_secs / seg.duration)
resp.close()
resp = _open(seg.upstream, target)
buf = _read_at_least(resp, b"", 16384)
pos = flac_sync(buf, 0)
buf = buf[pos:] if pos >= 0 else b""
else:
buf = buf[hlen:]
kind = kind or seg_kind
with session.cond:
if session.generation != gen:
resp.close() # player reconnected, a newer writer takes over
return sent
if session.closed or i >= len(session.segments) or session.segments[i] is not seg:
resp.close() # queue changed while the upstream was opening
continue
seg.streamed = True
writer.title = seg.label
writer.cover = seg.cover
log.info("gapless: streaming segment %d %r (%s)", i, seg.label, seg_kind)
# hold back the last 128 bytes so a trailing ID3v1 tag can be dropped
tail = b""
data = buf
while True:
if session.closed or session.generation != gen:
resp.close()
return sent
data = tail + data
tail = data[-128:]
if len(data) > 128:
try:
writer.write(data[:-128])
except Exception:
resp.close() # player went away - don't leave the server stream hanging
raise
more = resp.read(CHUNK)
if not more:
break
data = more
resp.close()
if not (seg_kind == "mp3" and tail[:3] == b"TAG" and len(tail) == 128):
writer.write(tail)
sent += 1
i += 1
if sent:
session.hold_until_played(gen)
if session.generation == gen:
session.ended = True
return sent