AI 工具

用 Groq Whisper 转写前校验音频文件

Groq 的语音转写走 OpenAI 兼容的 /openai/v1/audio/transcriptions。官方限制文件 ≤100MB,并限定扩展名。 这篇在发请求前先做本地校验,再用 multipart 上传;mock 返回假转写文本。

BINBHO · 图文教程

Groq 的语音转写走 OpenAI 兼容的 /openai/v1/audio/transcriptions。官方限制文件 ≤100MB,并限定扩展名。 这篇在发请求前先做本地校验,再用 multipart 上传;mock 返回假转写文本。

先看清成品

  • hello.wav 经 mock 返回「转写自 hello.wav」。
  • .txt 被本地拒绝(不支持的格式)。
  • 2 项 pytest 通过。未向 Groq 上传音频。

合规提醒:使用自己的 GroqCloud 账号与 API Key;语音内容需你有权处理。模型与价格以 groq.com/pricing 与控制台为准,本文不写价格。遵守各模型许可。

运行说明:没有可用的 Groq 密钥。真实运行过:流式聊天 / Whisper / tool-calling 客户端对本地 mock 的请求,以及对 https://api.groq.com 使用无效密钥得到 HTTP 401(2026-10-23,UTC+8)。未上传真实用户音频,未产生计费。

做完之后你会得到什么?

成果 说明
transcribe.py 校验 + 转写客户端
mock_whisper.py multipart mock
test_94b.py 2 项测试

需要准备哪些环境?

项目 说明
Python 3.11+ 标准库;pip install pytest
Groq Key(可选) https://console.groq.com → API Keys
音频样例(B) 小于 100MB,官方格式之一

关键事实

事实 来源
Speech-to-Text:whisper-large-v3 等,端点 /openai/v1/audio/transcriptions 官方 Speech to Text
文件大小上限 100 MB;支持 flac/mp3/mp4/mpeg/mpga/m4a/ogg/wav/webm 等 同上
请求为 multipart/form-data,字段含 file、model 同上

文件结构

94-groqcloud-B-src/
├── transcribe.py
├── mock_whisper.py
└── test_94b.py

第 1 步:客户端

#!/usr/bin/env python3
"""Groq Whisper transcription client with local file checks (size/format)."""
from __future__ import annotations

import argparse
import json
import mimetypes
import os
import sys
import uuid
import urllib.error
import urllib.request
from pathlib import Path

MAX_BYTES = 100 * 1024 * 1024
ALLOWED = {".flac", ".mp3", ".m4a", ".mpeg", ".mpga", ".ogg", ".wav", ".webm"}


def validate(path: Path) -> None:
    if not path.is_file():
        raise SystemExit(f"文件不存在: {path}")
    if path.suffix.lower() not in ALLOWED:
        raise SystemExit(f"不支持的格式 {path.suffix}(官方允许: {', '.join(sorted(ALLOWED))})")
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise SystemExit(f"文件 {size} 字节超过官方 100 MB 上限")
    if size == 0:
        raise SystemExit("空文件")


def transcribe(*, base_url: str, api_key: str, model: str, path: Path, language: str | None = None) -> dict:
    validate(path)
    boundary = f"----binbho{uuid.uuid4().hex}"
    mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    chunks: list[bytes] = []

    def field(name: str, value: str):
        chunks.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode())

    field("model", model)
    if language:
        field("language", language)
    data = path.read_bytes()
    chunks.append(
        f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{path.name}\"\r\n"
        f"Content-Type: {mime}\r\n\r\n".encode()
        + data
        + b"\r\n"
    )
    chunks.append(f"--{boundary}--\r\n".encode())
    body = b"".join(chunks)
    url = f"{base_url.rstrip('/')}/openai/v1/audio/transcriptions"
    req = urllib.request.Request(
        url,
        data=body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        raise SystemExit(f"HTTP {e.code}: {e.read().decode(errors='replace')}") from e


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("file", type=Path)
    p.add_argument("--base", default=os.environ.get("GROQ_BASE_URL", "https://api.groq.com"))
    p.add_argument("--key", default=os.environ.get("GROQ_API_KEY", ""))
    p.add_argument("--model", default="whisper-large-v3")
    p.add_argument("--language", default=None)
    args = p.parse_args(argv)
    if not args.key:
        print("设置 GROQ_API_KEY", file=sys.stderr)
        return 2
    r = transcribe(base_url=args.base, api_key=args.key, model=args.model, path=args.file, language=args.language)
    print(r.get("text", r))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

第 2 步:Mock

#!/usr/bin/env python3
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

API_KEY = "gsk-test"
MAX_BYTES = 100 * 1024 * 1024  # docs: 100 MB
ALLOWED = {".flac", ".mp3", ".m4a", ".mpeg", ".mpga", ".ogg", ".wav", ".webm"}


class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        if self.path != "/openai/v1/audio/transcriptions":
            self.send_error(404); return
        if self.headers.get("Authorization") != f"Bearer {API_KEY}":
            self.send_response(401); self.end_headers(); return
        ctype = self.headers.get("Content-Type", "")
        n = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(n)
        if n > MAX_BYTES:
            self.send_response(413); self.end_headers(); return
        # very small multipart parse: look for filename=
        if "filename=" in raw.decode("latin1", errors="ignore"):
            import re
            m = re.search(r'filename="([^"]+)"', raw.decode("latin1", errors="ignore"))
            name = m.group(1) if m else "x.wav"
        else:
            name = "x.wav"
        from pathlib import Path
        ext = Path(name).suffix.lower()
        if ext not in ALLOWED:
            self.send_response(400)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps({"error": {"message": f"unsupported format {ext}"}}).encode())
            return
        resp = {"text": f"转写自 {name}", "model": "whisper-large-v3"}
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(resp, ensure_ascii=False).encode())


if __name__ == "__main__":
    import os
    HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3096"))), H).serve_forever()

第 3 步:测试

import threading
from http.server import HTTPServer
from pathlib import Path
import mock_whisper
from transcribe import validate, transcribe

def serve():
    httpd = HTTPServer(("127.0.0.1", 0), mock_whisper.H)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd, httpd.server_address[1]

def test_validate_bad_ext(tmp_path: Path):
    f = tmp_path / "a.txt"
    f.write_text("x")
    try:
        validate(f)
        assert False
    except SystemExit as e:
        assert "不支持" in str(e)

def test_ok(tmp_path: Path):
    f = tmp_path / "hello.wav"
    f.write_bytes(b"RIFF" + b"\0" * 100)
    httpd, port = serve()
    try:
        r = transcribe(base_url=f"http://127.0.0.1:{port}", api_key=mock_whisper.API_KEY,
                       model="whisper-large-v3", path=f)
        assert "hello.wav" in r["text"]
    finally:
        httpd.shutdown()

第 4 步:运行

# 2026-10-23 12:37 UTC+8
{
  "text": "转写自 hello.wav",
  "model": "whisper-large-v3"
}
validate: 文件不存在: /tmp/a.txt

$ python -c "..."  # validate .txt
不支持的格式 .txt(官方允许: .flac, .m4a, .mp3, .mpeg, .mpga, .ogg, .wav, .webm)

$ python -m pytest -q
2 passed

怎么检查结果?

  • 超大/错误扩展名在本地失败,不产生网络请求。
  • 有 Key 时设 GROQ_API_KEY 与真实音频路径。

调试示例

空文件与假 wav。 测试用 RIFF + padding 只为通过扩展名与非空检查;真实转写需要合法音频。上线前用短录音验收模型与语言参数。

常见问题

现象 原因 处理
413/报错过大 超过 100MB 压缩或切片
格式不支持 扩展名不在列表 先转 wav/mp3
语言乱 未设 language 按官方传 language 代码

模板规则

  • 先本地校验再上传。
  • 只处理有权处理的音频。
  • 密钥放环境变量。

项目资源

用途 地址
Speech to Text(官方) https://console.groq.com/docs/speech-to-text
OpenAI Compatibility https://console.groq.com/docs/openai
Pricing https://groq.com/pricing

文件门禁能挡住大部分无效请求和意外超大上传。