Without Streaming

A Telegram channel as a radio station

6 notes · 9 min

Without Streaming: a Telegram channel as a radio station: illustration 1

Contents

  1. Radio from Telegram in one click1 min
  2. Two mood knobs: style and energy2 min
  3. How it works inside: indexer → playlist → streamvideo1 min
  4. Engineering reality: the player, background and PWAvideo1 min
  5. The project moves slowlyphoto1 min
  6. Without Streaming stopped being a player and became a radio stationvideo3 min

Radio from Telegram in one click

I have a Telegram channel, Without Streaming, where I record my personal music library, as well as short video cuts from my projects @arsenynn and workflow@arsenynn. For me it’s not just a selection but an imprint of taste and time.

It all started pragmatically: come to a gathering, a party or get into a car, and instead of connecting to a speaker or player, just open your channel on the phone of whoever is already connected and turn on music in one step.

Music and GIFs work here as a compact record of a state: together they preserve the mood of a moment faster and more precisely than any explanation.

Telegram as a CMS. A new post in the channel automatically becomes part of the broadcast, with no separate admin panel or media library.

Visuals as a “TV channel”. One visual repeats three times, then switches to the next; the starting set is shuffled every time.

Audio is streamed on the fly from Telegram without being saved to disk. This is a conscious choice: minimum infrastructure and minimum “traces”.

The technical projection is simple: FastAPI + Telethon → JSON playlist → chunked stream

In the next post I’ll explain how two knobs (Jazz ⇄ Hip-hop and Drifting ⇄ Driving) control the mood of the broadcast through simple tags in Telegram.

Back to contents

Two mood knobs: style and energy

In Without Streaming I made the controls like a mixing desk: one knob shifts the broadcast Jazz ⇄ Hip-hop, the other changes the energy Drifting ⇄ Driving.

The basis is deliberately simple and transparent: I tag tracks in Telegram (#J / #H / #JxH and #drift / #drive / #dd). An indexer collects this into playlist.json, and on a /playlist?mix=&energy= request the server rebuilds the track order. #JxH and #dd are tracks I myself consider mixed in style (Jazz×Hip-hop) and tracks of mixed energy (between drift and drive).

Then the code does a simple thing: it turns the slider positions into coefficients of “how much attention to give each side” and calculates the track’s final score. The higher the score, the higher the track rises in the /playlist output.

jazz_bias   = 1.0 - hiphop_bias

drive_bias  = energy / 100.0
drift_bias  = 1.0 - drive_bias

score = jazz_bias * w_jazz + hiphop_bias * w_hiphop \
      + drift_bias * w_drift + drive_bias * w_drive
  • mix and energy turn into *_bias coefficients: this is “how much influence” to give each side of the scale.
  • w_jazz / w_hiphop / w_drift / w_drive are track weights derived from tags during indexing.
  • score is the final rating: the higher it is, the sooner the track will be on air at the current settings.

Then an important engineering part comes in: to keep the broadcast from looping within a narrow category, the server first serves the “matching” tracks (and bridges), then mixes in the rest.

Why it’s not perfect yet: tags are subjective, two axes create many combinations, and “musically right” doesn’t always match “highest score”.

Auto modes (Auto Flow / Auto Energy) move the knobs smoothly and don’t break the current track; the effect shows on the next one. By the way, auto mode can be turned on right from the slider: press the “dot” (the slider) and it starts moving on its own.

In the next post I’ll go through the inner pipeline of Without Streaming: how the indexer builds playlist.json, how /playlist shapes the broadcast to the knobs, and how /audio streams media from Telegram chunked without saving to disk.

Back to contents

How it works inside: indexer → playlist → stream

The project is built as a pipeline: Telegram channel → catalogue → broadcast.

1) The indexer runs through the channel, finds audio and GIF/MP4, reads tags and builds playlist.json.

2) /playlist?mix=&energy= returns JSON with tracks and visuals already in the right order for the current knobs.

3) /audio/{id} and /gif/{id} request media from Telegram and serve it to the browser.

The key principle: audio is not saved to disk. The server streams the data (chunked) as it arrives.

Visuals are cached to disk because they’re short and repeat, and the cache noticeably speeds up switching.

The most important part (the thing that turns a channel into a broadcast):

@app.get("/audio/{message_id}")
async def audio(message_id: int):
    async def gen():
        async for chunk in telethon_stream_bytes(message_id):
            yield chunk

    return StreamingResponse(
        gen(),
        media_type="audio/mpeg",
        headers={"Cache-Control": "no-store", "Accept-Ranges": "none"},
    )

— @app.get(“/audio/{message_id}”) The route: GET /audio/12345 lands here, {message_id} is the ID of the message with the track in Telegram.

— async def audio(message_id: int): An asynchronous handler, so the server doesn’t block during loading and serving. int fixes the parameter type.

— async def gen(): … yield chunk An asynchronous generator: it reads the track from Telegram in portions (async for) and immediately serves each piece (yield).

— StreamingResponse(gen(), …) Wraps the flow in a proper HTTP stream: playback can start before the file has fully loaded.

— media_type=“audio/mpeg” Explicitly sets Content-Type so the browser plays the audio correctly.

— headers no-store: don’t cache the audio. Accept-Ranges: none: no Range requests (seeking or loading “from the middle” isn’t provided for).

Back to contents

Engineering reality: the player, background and PWA

Making a “playlist in the browser” is easy. Making it feel like radio is harder: you need seamless sound, a stable UI and behaviour that doesn’t break when conditions change (network, device, background).

1) The player as an engine

The “radio” effect rests on two things: crossfade and states. The player works as a state machine (idle → buffering → playing) so there’s no “double sound” and no races during switches. In parallel the next track is prefetched so that reactions to Next/Prev feel fast.

2) Why there’s a “Listen in background” button

On iOS/WebView the “rich” mode (WebAudio, video loops, timers, prefetch) is often cut aggressively when the app is minimised. So the “Listen in background” button moves the radio to a lighter profile: it turns off the heavy parts, leaves stable playback through a simple <audio>, and gives a quick return to the main mode without losing context.

3) PWA

PWA here isn’t about being “native”. It gives a more predictable interface launch and raises the chance that a session will live longer. But I don’t promise the impossible: background on the web can’t be guaranteed, which is why a separate “background mode” is needed.

4) Gesture gate: Telegram Mini App / Safari WebView

There’s one more “grown-up” layer of reliability: when a platform forbids autoplay (especially in WebView), the project doesn’t look broken. Visuals go into a waiting mode until the first user gesture and after a tap start again neatly. It’s a rare but important detail: in this situation most web players silently “don’t play” and look like a bug, while here it’s a meaningful degradation for real constraints.

Telegram content turns into a media object rather than a feed of links, and the system honestly takes platform constraints into account.

Launching from the browser 🎵

Back to contents

Without Streaming stopped being a player and became a radio station

When I wrote the first posts about Without Streaming, the word “radio” was more of a descriptive form.

Back then each person was effectively running their own player. The browser got a playlist from Telegram, chose the track sequence itself, managed transitions, and the two knobs Jazz ⇄ Hip-hop and Drifting ⇄ Driving let everyone assemble their own version of the stream.

It worked, but real radio has one fundamental property: at any moment there is one broadcast for everyone.

That’s exactly what I eventually rebuilt completely.

Now a person who opens Without Streaming doesn’t get their own queue and doesn’t start a track from the beginning. They connect to the point that is on air right now.

Inside, the architecture now looks roughly like this:

Telegram local media library Python Liquidsoap AAC-LC HLS Caddy one shared live broadcast

In the simplest words:

Liquidsoap is the radio engine. It continuously plays the selected tracks, makes transitions between them and forms the very signal that’s actually on air at that moment.

AAC-LC is an audio codec: it compresses this signal into a format that can be sent to listeners over the internet economically enough.

HLS is a way of delivering a live stream. Instead of one endless connection, the broadcast is constantly cut into short segments, and the browser fetches them in sequence and so can connect to the current moment of the broadcast.

Caddy is the web server at the end of this chain. It serves HLS, the site and other public files to listeners.

The most important change here isn’t even related to Liquidsoap or HLS but to the separation of responsibility.

Python decides what should sound next.

Liquidsoap is the actual point of broadcast.

The browser no longer programmes anything, it only listens.

Previously, during Play, a track was requested directly from Telegram and streamed to the browser. Now the whole usable library is mirrored locally and can keep playing regardless of whether Telegram is available.

So Telegram gradually turned from:

CMS + file server + runtime dependency

into:

CMS + source of publication and of the material’s origin.

The client changed too.

Instead of a browser audio engine there’s now one permanent <audio> connected to HLS.

After a pause, Play now returns the listener not to the old position but back to the current broadcast.

Visuals exist separately from the music programme. They’re prepared locally, served as static MP4 and rotate independently for each listener.

That’s why the old Jazz / Hip-hop and Drifting / Driving knobs disappeared from the interface, and not by accident. The idea isn’t discarded, but control of the music logic is gradually moving to where it now belongs: into server-side programming of the shared broadcast rather than each listener’s personal player.

In parallel the radio got a separate operational layer. Now I can see how many people are on the site, how many are really listening to HLS, the daily number of listening sessions and the peak.

The next stage for me now isn’t about new buttons. I’m bringing the system to a state where it recovers on its own after process crashes, network loss and reboot, then I’ll measure the real throughput of the home server, and only after that continue building the Programming Engine.

And then begins perhaps the most interesting part of Without Streaming’s development. The music programme will gradually become much more complex, and things will appear on air that have never been in the project before. But that’s for another time.

The result is a little paradoxical: the more complex Without Streaming gets inside, the fewer actions remain for the listener outside :)

😎 Launch Without Streaming 👈👈

😎 Without Streaming Live mix

Back to contents

Next seriesMac Automation Stack