跳到正文
DEV Community· Ismailov Uluk·· 3 小時前AI 評分56

Go Telegram 語音轉錄機械人:改用 ElevenLabs、重啟時排空佇列及每小時音訊的實際成本

A Telegram transcription bot in Go: ElevenLabs instead of Whisper, draining the queue on restart, and what an hour of audio really costs

AI 導讀

作者以 Go 建置的 Telegram 語音轉錄機械人採用 ElevenLabs Scribe v2 為主引擎、Groq Whisper 為後備,並在關機時排空工作佇列。

正文

A few days ago I shipped a Telegram bot that turns voice notes, files up to 2 GB and YouTube links into text with timestamps, speaker labels and a summary. This is the technical side: how the pipeline is built, why I moved off Whisper, where the real cost hides, and what breaks when you restart a bot that keeps its job queue in memory.

The code is closed, but every snippet below is real.

One job, start to finish

Go with the telego library, long polling, a buffered channel of 100 jobs and three workers. A job goes: download (a local Bot API server gives a path on a shared volume, yt-dlp for links), ffprobe for duration and a balance check before any heavy work, ffmpeg to 16 kHz mono FLAC, recognition, save to SQLite, charge minutes, deliver.

FLAC instead of WAV or MP3 is not aesthetics: it is lossless, roughly half the size of WAV, and both ElevenLabs and Groq accept it directly. For an hour-long call that is about 60 MB instead of 115, and that is exactly the traffic that leaves the box on every recording.

Two engines and the fallback

The primary engine is ElevenLabs Scribe v2, the fallback is Groq Whisper. The pipeline tries engines in order and moves to the next one on any error except context cancellation:

func (p *Pipeline) Run(ctx context.Context, flacPath string, duration float64, workDir string, progress ProgressFunc) (*Result, error) {
    var failures []error
    for index, engine := range p.Engines {
        result, err := p.runEngine(ctx, engine, flacPath, duration, workDir, progress)
        if err == nil {
            result.Engine = engine.Name
            return result, nil
        }
        if ctx.Err() != nil {
            return nil, ctx.Err()
        }
        failures = append(failures, fmt.Errorf("%s: %w", engine.Name, err))
        if index < len(p.Engines)-1 && p.Logger != nil {
            p.Logger.Warn("speech engine failed, falling back", "engine", engine.Name, "next", p.Engines[index+1].Name, "error", err)
        }
    }
    if len(failures) == 0 {
        return nil, errors.New("no speech engines configured")
    }
    return nil, errors.Join(failures...)
}

The engines differ in more than quality. ElevenLabs takes the whole recording in one request, so speaker numbers stay consistent across the full length. Groq accepts files up to 25 MB, so for it the recording has to be cut into chunks. Cutting on a timer is wrong: a word split in half is lost in both halves. ffmpeg's silencedetect filter gives the middle of every pause, and the planner looks for a pause within a 30 second window around the ideal boundary:

func PlanChunks(duration, chunkSeconds, searchWindow float64, silences []float64) []Chunk {
    count := int(math.Ceil(duration / chunkSeconds))
    if count <= 1 {
        return []Chunk{{Start: 0, End: duration}}
    }
    target := duration / float64(count)
    chunks := make([]Chunk, 0, count)
    start := 0.0
    for index := 1; index < count; index++ {
        ideal := target * float64(index)
        cut, bestDistance := ideal, searchWindow
        for _, silence := range silences {
            if distance := math.Abs(silence - ideal); distance <= bestDistance && silence > start+target/2 {
                cut, bestDistance = silence, distance
            }
        }
        chunks = append(chunks, Chunk{Start: start, End: cut})
        start = cut
    }
    return append(chunks, Chunk{Start: start, End: duration})
}

Chunks go out in parallel through errgroup with a worker limit, results are stitched in order with a timestamp offset. The price: after stitching there are no speakers on the Groq path, and before the text reaches the user you have to filter Whisper's hallucinations on silence, the famous "Subtitles by ..." lines.

Why not Whisper

I ran the same Russian clip with technical terms through two popular bots, both on Whisper. Both turned "backend" into "backing". Scribe v2 makes roughly half as many errors on my tests, labels speakers, and costs about $0.22 per hour of audio. Groq Whisper is cheaper at $0.11 per hour and stayed as the fallback: when my ElevenLabs key hit a permissions problem (a 401 from key scopes, not from money), users did not notice.

There is no language setting at all. I first built a /language command and then removed it: auto-detection on mixed Russian and English works better than people switching settings.

Where the cost actually is

Intuition says long recordings are expensive. They are not: recognition is linear, there is one summary per recording, and an hour of lecture costs 29 to 43 rubles (about $0.35 to $0.50) including a couple of model actions.

The expensive part is elsewhere. I measured real Claude usage on short voice notes: at medium effort the model spends 260 to 420 tokens on reasoning, prompt caching kicks in at about 1,100 input tokens, and a short voice note never reaches that. So a summary for a 30 second note costs the same as for an hour, about 2 rubles on Opus. If a user taps "summary" under every voice note, an hour of such notes cost 157 rubles.

The fix is one function: the expensive model only for paying users and only on recordings of five minutes or more, everything else on Haiku.

func (b *Bot) aiModel(ctx context.Context, userID int64, record *transcript.Transcript) string {
    if record.DurationSeconds >= float64(b.cfg.PremiumMinMinutes)*60 && b.isPremium(ctx, userID) {
        return b.cfg.ClaudeModel
    }
    return b.cfg.ClaudeModelFree
}

On a short voice note the quality difference between the models is invisible. The worst-case hour went from 157 to 22 rubles.

One detail that matters for the cache: every action on a recording (summary, tasks, question) sends the model the same text. For a video by link it is the text with timestamps, so the summary can end with chapters; for everything else it is the dialogue by speaker. Send a different variant even once and the prompt cache misses.

Restarting with the queue in memory

The job queue lives in a channel, not in the database. The first version closed the channel on shutdown and panicked when a handler tried to push a job into it. The second version just exited, and jobs people had already sent were lost.

Third version: polling stops, workers finish what is already queued, with a time limit.

err = handler.Start()
drainDeadline := time.Now().Add(jobDrainTimeout)
for (len(b.jobs) > 0 || runningJobs.Load() > 0) && time.Now().Before(drainDeadline) {
    time.Sleep(500 * time.Millisecond)
}
cancelJobs()
workers.Wait()

Telegram never redelivers an update the process has already acknowledged, so the workers live on their own context, not on the polling context. In docker-compose this becomes a stop_grace_period longer than the drain timeout, otherwise Docker kills the container after 10 seconds and none of this logic ever runs in production. I checked it the same day: on shutdown the bot finished a 14 minute recording for a user who never knew.

SQLite and a single connection

modernc.org/sqlite without cgo, WAL, and db.SetMaxOpenConns(1). One connection serializes writers, and the only rule you must keep is to never query db while a transaction is open, or you deadlock. Charging minutes is one transaction: read the balance, check, update, write a usage row. Free monthly minutes are spent before purchased ones, and a failed recognition charges nothing, because charging happens after the transcript is saved.

The payment webhook

Tribute signs the request body with HMAC-SHA256 using the API key and puts it in the trbt-signature header. The docs do not say whether it is hex or base64; the test request showed hex, but I accept both, with constant-time comparison:

func VerifySignature(body []byte, signature, apiKey string) bool {
    signature = strings.TrimSpace(signature)
    if apiKey == "" || signature == "" {
        return false
    }
    mac := hmac.New(sha256.New, []byte(apiKey))
    mac.Write(body)
    expected := mac.Sum(nil)
    if decoded, err := hex.DecodeString(signature); err == nil && hmac.Equal(decoded, expected) {
        return true
    }
    if decoded, err := base64.StdEncoding.DecodeString(signature); err == nil && hmac.Equal(decoded, expected) {
        return true
    }
    return false
}

The body is capped at a megabyte, the signature is checked before parsing JSON, purchases and refunds are idempotent by event key, and a 401 or 400 means "do not retry" while a 500 means "retry later". The first real purchase came through this webhook the same day the packs passed moderation.

Groups and privacy

In groups the bot sees media but does not store message text: it only needs the file. Voice notes up to three minutes are transcribed automatically; under longer ones and under video links a button appears, and nothing is downloaded or charged until someone taps it. The full transcript opens in a private chat via a /start t_ link, and the bot checks through getChatMember that the person is still in the chat, with a 10 minute cache.

What I would do differently

The queue in SQLite from day one, not in a channel. A separate free limit for the first video: the first outside user hit "not enough minutes" on a long lecture before seeing a single summary. And not spending two hours hunting a pipeline bug when voice notes arrive empty: a level of minus 56 dB is the microphone, not the code.

Bot: https://t.me/Voicepad_bot?start=src_devto, 15 minutes a month free. Questions about ElevenLabs, chunking and token economics are welcome in the comments.

來源:DEV Community · dev.to