1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"time"
)

// Transcription
// ============================================================================
//
// Only ever with the sender's consent. A submission is someone else's voice,
// and sending it to Google is a decision that belongs to them, not to us — so
// the checkbox on the form is unticked by default and this file is only
// reached when it was ticked. Without consent the recording never leaves the
// machine it was uploaded to.
//
// The transcript is a convenience for triage: it makes it possible to see what
// a submission is about without listening to five minutes of it. Nothing
// depends on it, and a failure here is reported in the notification while the
// recording itself is untouched.

// geminiModel is the model transcription runs on.
//
// Google retires model names and refuses them for new callers rather than
// silently redirecting — the API answers "no longer available to new users"
// with the name of the successor, which is how this one was last updated. A
// failure here costs a transcript, never a recording.
const geminiModel = "gemini-3.6-flash"

const geminiAPI = "https://generativelanguage.googleapis.com/v1beta/models/" +
	geminiModel + ":generateContent"

const transcribePrompt = `Transcribe this audio recording verbatim.

Output only the transcript text. Do not summarise, do not add commentary, do
not add speaker labels unless there is clearly more than one speaker. Use
ordinary punctuation and paragraph breaks. If the audio is unintelligible or
contains no speech, output exactly: (no speech)`

// transcribeTimeout bounds one transcription. Submissions are capped at five
// minutes of audio, which Gemini handles well inside this.
const transcribeTimeout = 3 * time.Minute

type geminiRequest struct {
	Contents []geminiContent `json:"contents"`
}

type geminiContent struct {
	Role  string       `json:"role,omitempty"`
	Parts []geminiPart `json:"parts"`
}

type geminiPart struct {
	Text       string            `json:"text,omitempty"`
	InlineData *geminiInlineData `json:"inline_data,omitempty"`
}

type geminiInlineData struct {
	MimeType string `json:"mime_type"`
	Data     string `json:"data"`
}

type geminiResponse struct {
	Candidates []struct {
		Content struct {
			Parts []struct {
				Text string `json:"text"`
			} `json:"parts"`
		} `json:"content"`
		FinishReason string `json:"finishReason"`
	} `json:"candidates"`
	Error *struct {
		Message string `json:"message"`
	} `json:"error"`
}

// transcribe sends audio to Gemini and returns the transcript.
func transcribe(ctx context.Context, apiKey string, audio []byte, mimeType string) (string, error) {
	if apiKey == "" {
		return "", fmt.Errorf("no Gemini API key configured")
	}
	ctx, cancel := context.WithTimeout(ctx, transcribeTimeout)
	defer cancel()

	body := geminiRequest{
		Contents: []geminiContent{{
			Role: "user",
			Parts: []geminiPart{
				{InlineData: &geminiInlineData{
					MimeType: mimeType,
					Data:     base64.StdEncoding.EncodeToString(audio),
				}},
				{Text: transcribePrompt},
			},
		}},
		// No GenerationConfig. Transcription is mechanical, so disabling thinking
		// with `thinkingBudget: 0` would be the obvious economy — but this model
		// rejects that outright with a bare "Request contains an invalid
		// argument", naming no field. The request is otherwise identical, so the
		// budget is simply left to the model's default.
	}
	bodyJSON, err := json.Marshal(body)
	if err != nil {
		return "", fmt.Errorf("marshal request: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, "POST", geminiAPI, bytes.NewReader(bodyJSON))
	if err != nil {
		return "", fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-goog-api-key", apiKey)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("http request: %w", err)
	}
	defer resp.Body.Close()

	var result geminiResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("decode response: %w", err)
	}
	if result.Error != nil {
		return "", fmt.Errorf("gemini API error: %s", result.Error.Message)
	}
	if len(result.Candidates) == 0 {
		return "", fmt.Errorf("no candidates in response")
	}
	cand := result.Candidates[0]
	if len(cand.Content.Parts) == 0 {
		// A candidate with no parts is how the API reports a safety block or
		// a token limit; the finish reason is the only useful signal.
		if cand.FinishReason != "" && cand.FinishReason != "STOP" {
			return "", fmt.Errorf("generation stopped: %s", cand.FinishReason)
		}
		return "", fmt.Errorf("no content in response")
	}
	text := strings.TrimSpace(cand.Content.Parts[0].Text)
	if text == "" {
		return "", fmt.Errorf("empty transcript in response")
	}
	return text, nil
}