Skip to content

Voice Chat System

The voice chat subsystem is the heart of Solstice's music streaming capability. It bridges the Go engine with Telegram's voice chat protocol through a Python daemon powered by PyTgCalls.

Architecture

┌───────────────────────────────────┐
│          Go Engine                │
│                                   │
│  VoiceChatManager (vc.go)         │
│  ├── StartDaemon()                │
│  ├── Play(chatID, url, video)     │
│  ├── Pause/Resume/Stop(chatID)    │
│  ├── Seek(chatID, position)       │
│  ├── Volume(chatID, level)        │
│  └── OnStreamEnd callback        │
│                                   │
│  HTTP listener on :5051           │
│  └── /stream_end endpoint         │
└───────────────┬───────────────────┘
                │ HTTP POST (JSON)

┌───────────────────────────────────┐
│       Python Daemon               │
│       (vc_daemon.py)              │
│                                   │
│  aiohttp server on :5050         │
│  ├── POST /play                   │
│  ├── POST /pause                  │
│  ├── POST /resume                 │
│  ├── POST /stop                   │
│  ├── POST /seek                   │
│  └── POST /volume                 │
│                                   │
│  Pyrogram Client (MTProto)        │
│  └── Joins/leaves voice chats     │
│                                   │
│  PyTgCalls Engine                 │
│  └── Streams audio/video via      │
│      FFmpeg → Telegram VC         │
└───────────────────────────────────┘

VoiceChatManager (Go Side)

File: internal/telegram/vc.go

The VoiceChatManager struct manages the lifecycle of the Python daemon and provides a clean Go API for voice chat operations.

Key Methods

go
type VoiceChatManager struct {
    cmd         *exec.Cmd
    OnStreamEnd func(chatID int64)  // Callback wired by main.go
}

func NewVoiceChatManager() *VoiceChatManager
func (vc *VoiceChatManager) StartDaemon()
func (vc *VoiceChatManager) Play(chatID int64, streamURL string, isVideo bool, title string) error
func (vc *VoiceChatManager) Pause(chatID int64) error
func (vc *VoiceChatManager) Resume(chatID int64) error
func (vc *VoiceChatManager) Stop(chatID int64) error
func (vc *VoiceChatManager) Seek(chatID int64, position int) error
func (vc *VoiceChatManager) Volume(chatID int64, level int) error

How Commands Work

Each method sends an HTTP POST to http://localhost:5050/<endpoint> with a JSON body:

go
func (vc *VoiceChatManager) Play(chatID int64, streamURL string, isVideo bool, title string) error {
    payload := map[string]interface{}{
        "chat_id":    chatID,
        "stream_url": streamURL,
        "video":      isVideo,
        "title":      title,
    }
    body, _ := json.Marshal(payload)
    resp, err := http.Post("http://localhost:5050/play", "application/json", bytes.NewBuffer(body))
    // ... handle response
}

Stream End Webhook

The Go engine also runs a lightweight HTTP server on :5051 that listens for stream-end notifications:

go
// In StartDaemon or init:
http.HandleFunc("/stream_end", func(w http.ResponseWriter, r *http.Request) {
    var req struct{ ChatID int64 `json:"chat_id"` }
    json.NewDecoder(r.Body).Decode(&req)
    if vc.OnStreamEnd != nil {
        go vc.OnStreamEnd(req.ChatID)
    }
    w.WriteHeader(200)
})
go http.ListenAndServe(":5051", nil)

Auto-Stop / Alone Detection Webhook

The webhook server also listens for /vc_stopped notifications. This is triggered when the assistant is left alone in a voice chat:

  1. The Python daemon monitors UpdatedGroupCallParticipant updates.
  2. If the assistant is the only user left in the voice chat, a 10-minute auto-stop countdown starts.
  3. If someone joins within the 10 minutes, the countdown is canceled and playback continues.
  4. If it remains empty after 10 minutes, the assistant leaves the call and triggers /vc_stopped, clearing the queue and logging the event to LOGGER_ID.

Python Daemon (vc_daemon.py)

File: internal/telegram/vc_daemon.py

The daemon is a standalone asyncio application that:

  1. Initializes a Pyrogram client using SESSION_STRING, API_ID, and API_HASH
  2. Initializes a PyTgCalls instance attached to the Pyrogram client
  3. Starts an aiohttp HTTP server on port 5050
  4. Handles incoming requests from the Go engine
  5. Sends stream_end notifications back to Go when a stream finishes

Daemon Startup Sequence

python
async def main():
    # 1. Initialize Pyrogram client
    app = Client("solstice_vc", api_id=API_ID, api_hash=API_HASH,
                 session_string=SESSION_STRING)
    await app.start()

    # 2. Initialize PyTgCalls
    pytgcalls = PyTgCalls(app)
    await pytgcalls.start()

    # 3. Register stream-end callback
    @pytgcalls.on_stream_end()
    async def on_stream_end(client, update):
        chat_id = update.chat_id
        # Notify Go engine
        async with aiohttp.ClientSession() as session:
            await session.post("http://localhost:5051/stream_end",
                             json={"chat_id": chat_id})

    # 4. Start HTTP API server
    web_app = web.Application()
    web_app.router.add_post("/play", handle_play)
    web_app.router.add_post("/pause", handle_pause)
    web_app.router.add_post("/resume", handle_resume)
    web_app.router.add_post("/stop", handle_stop)
    web_app.router.add_post("/seek", handle_seek)
    web_app.router.add_post("/volume", handle_volume)

    runner = web.AppRunner(web_app)
    await runner.setup()
    site = web.TCPSite(runner, "localhost", 5050)
    await site.start()

Play Handler

python
async def handle_play(request):
    data = await request.json()
    chat_id = data["chat_id"]
    stream_url = data["stream_url"]
    is_video = data.get("video", False)
    title = data.get("title", "")

    if is_video:
        stream = AudioVideoPiped(stream_url,
                                 video_parameters=VideoParameters(...))
    else:
        stream = AudioPiped(stream_url,
                           audio_parameters=AudioParameters(...))

    try:
        await pytgcalls.join_group_call(chat_id, stream)
    except AlreadyJoinedError:
        await pytgcalls.change_stream(chat_id, stream)

    return web.json_response({"status": "ok"})

Stream Parameters

ModeFormatQuality
Audiobestaudio[ext=m4a] / format 251/25048kHz, stereo
Videobest[ext=mp4] / format 22/18720p preferred

Queue Management

File: internal/queue/queue.go

The QueueManager is a thread-safe, per-chat song queue implemented in Go.

Data Structures

go
type Song struct {
    Title         string
    Link          string
    Duration      string
    Thumbnail     string
    StreamURL     string
    RequesterID   int64
    RequesterName string
    Video         bool
}

type QueueManager struct {
    mu     sync.Mutex
    queues map[int64][]Song  // chatID → ordered song list
}

Operations

MethodDescription
Add(chatID, song)Append song to the chat's queue
GetQueue(chatID)Return the full queue (read-only copy)
GetCurrent(chatID)Return the first song (currently playing)
Advance(chatID)Pop the first song, return the next one
Clear(chatID)Empty the entire queue
Shuffle(chatID)Randomize queue order (preserves current song at index 0)
Remove(chatID, index)Remove a specific song by index
GetActiveChats()Return all chat IDs with non-empty queues
Length(chatID)Return queue length

Thread Safety

All operations acquire sync.Mutex before modifying state. This is critical because:

  • Multiple users can issue /play commands simultaneously
  • The stream-end webhook handler runs in a separate goroutine
  • The /shuffle, /skip, and /stop commands can race with auto-advance

Stream Lifecycle

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  IDLE    │────▶│ PLAYING  │────▶│ PAUSED   │────▶│ PLAYING  │
│          │     │          │     │          │     │          │
│ No VC    │     │ Stream   │     │ Stream   │     │ Stream   │
│ session  │     │ active   │     │ suspended│     │ resumed  │
└──────────┘     └────┬─────┘     └──────────┘     └──────────┘

                      │ stream ends

               ┌──────────────┐
               │  ADVANCING   │
               │              │
               │ Pop queue    │
               │ Check next   │
               └──────┬───────┘

            ┌─────────┼──────────┐
            │         │          │
            ▼         ▼          ▼
     ┌──────────┐  ┌─────────┐  ┌──────────┐
     │ PLAYING  │  │AUTOPLAY │  │  IDLE    │
     │ next     │  │ fetch   │  │ queue    │
     │ song     │  │ related │  │ empty    │
     └──────────┘  └─────────┘  └──────────┘

Autoplay

When enabled (/autoplay), Solstice fetches related songs when the queue empties:

  1. Take the last played song's YouTube video ID
  2. Call youtube.GetRelatedVideos(videoID, 1) - fetches from YouTube Mix (RD playlist)
  3. Extract the stream URL for the first related video
  4. Add it to the queue and trigger playback

This creates an infinite, contextually relevant music stream.


Vote Skip

Normal (non-admin) users can vote to skip a song instead of directly skipping:

  1. User clicks the 👍 Vote Skip (N/T) button on the Now Playing card
  2. voteSkipStore.toggle() adds/removes their vote (thread-safe via mutex)
  3. The button text updates to show current count: 👍 N/T
  4. When N >= T (threshold set via /setvotes), the song is auto-skipped
  5. Vote data is cleared when a song ends or is manually skipped
go
type voteSkipStore struct {
    mu    sync.Mutex
    votes map[int64]map[int]map[int64]bool  // chatID → messageID → userID → voted
}

Released under the GPL-3.0 License.