HomeBlog → Speech to text in Go: working code on the standard library

Speech to text in Go: working code on the standard library

Published 2026-08-20 · 5 min read

Go needs neither an SDK nor third-party packages to upload audio: a multipart request is twenty lines of standard library. Below is a complete function, sane error handling and a worker pool for chewing through an archive of recordings.

The upload function

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"time"
)

func transcribe(path, key string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	var body bytes.Buffer
	w := multipart.NewWriter(&body)
	part, _ := w.CreateFormFile("file", f.Name())
	if _, err := io.Copy(part, f); err != nil {
		return "", err
	}
	w.WriteField("model", "whisper-1")
	w.Close()

	req, _ := http.NewRequest("POST", "https://voicesscribe.com/v1/audio/transcriptions", &body)
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Content-Type", w.FormDataContentType())

	client := &http.Client{Timeout: 3 * time.Minute}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var out struct {
		Text  string
		Error struct{ Message string }
	}
	json.NewDecoder(resp.Body).Decode(&out)
	if resp.StatusCode != 200 {
		return "", fmt.Errorf("status %d: %s", resp.StatusCode, out.Error.Message)
	}
	return out.Text, nil
}

Note the client timeout: the default is none at all, so a request on a long recording can hang forever.

Handling errors

Error responses arrive in the familiar shape — an error object with a message field. Split failures into two groups: permanent ones (400, 401, 413) and temporary ones (429, 502, network drops). Repeating the first group is pointless; the second deserves a retry with a growing pause.

func withRetry(path, key string) (string, error) {
	delay := 2 * time.Second
	var last error
	for i := 0; i < 4; i++ {
		text, err := transcribe(path, key)
		if err == nil {
			return text, nil
		}
		last = err
		if !temporary(err) {
			break
		}
		time.Sleep(delay)
		delay *= 2
	}
	return "", last
}

A worker pool for archives

This is where Go shines: a bounded pool is a few lines and the bound protects you from rate-limit rejections.

files := make(chan string)
var wg sync.WaitGroup

for i := 0; i < 4; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for path := range files {
			text, err := withRetry(path, key)
			if err != nil {
				log.Println(path, err)
				continue
			}
			os.WriteFile(path+".txt", []byte(text), 0o644)
		}
	}()
}

Size the pool by the requests-per-second limit of your plan — launching a hundred goroutines only produces a hundred rejections.

Production notes

Try it on your own recordings. Sign-up takes a minute, and the free minutes are enough to judge the quality.

Get a free API key

Frequently asked questions

Is there an SDK for Go?

You do not need one. A multipart request on the standard library covers the whole API surface used here.

Why do long uploads get cut off?

http.Client has no default timeout, but proxies and load balancers do. Set your own timeout of a couple of minutes.

How do I pass the language or a prompt?

As extra form fields: language and prompt next to the model field.

How many goroutines should I run?

As many as your plan's rate limit allows. Exceeding it returns 429 and makes you redo the work.

Related reading