开发者
以 API 提供的 Euphona 引擎
基于 api.euphonaai.com/v1 的一套 REST 接口约定。异步任务与 SSE 实时进度、签名文件传输、带前缀的密钥和免费测试模式:这是一款第一方产品,有自己的 12 个月支持期。
密钥在 Studio 设置中创建:eu_test_… 可以立即免费创建;绑定银行卡后,可以在同一页面创建 eu_live_…,从第一个任务开始计费。测试模式可以运行完整的接口约定。
一切都是任务
curl -X POST https://api.euphonaai.com/v1/masters \
-H "Authorization: Bearer $EUPHONA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"file_id": "file_8Kd2mQTz3XaV7pLw"}'
HTTP/2 202
{
"id": "job_7Vd2mHs5Yq8Tz3Xa",
"object": "job",
"type": "mastering",
"status": "queued",
"stage": null,
"metering": {
"pool": "payg",
"charged_minutes": 0,
"test_mode": false
}
}设计
设计原则
四项不容商量的决定。每一项都决定了一个集成是能挺过第一次故障,还是挺不过去。
一切都是任务
功能端点返回 202 和一个任务资源。没有同步处理端点,将来也不会有:按母带质量处理一首三分钟的歌,不是一次请求/响应循环;假装可以的 API,会让每个集成都写错。
音频绕开 API,而不是经过 API
POST /v1/files 会返回一个位于 files.euphonaai.com 上的签名、可续传的上传目标。结果以会过期的签名下载 URL 返回。API 本身是一个 JSON 控制平面,从不传输一个字节的音频。
这个 API 就是产品,而不是副产品
Euphona Studio 使用的是另一套第一方 API。这是有意为之:两者是共享同一个引擎的两款独立产品,所以应用想要的改动不会波及这套接口约定,而这套约定的 12 个月支持期也不会成为应用必须承担的负担。如实说,这样做的后果是应用可能会做到这个 API 做不到的事:如果这对你很重要,请告诉我们,它会成为路线图上的一项,而不是一个意外。
文档就是接口约定
OpenAPI 3.1 文档是唯一的事实来源。服务器路由和类型都由它生成,一项在部署时把关的测试会确保实际提供的路由与声明的操作完全一致:因此,文档与实际服务之间一旦出现偏差,构建就会失败,而不是靠我们承诺会留意。由同一份文档生成的 SDK 会随开发者预览版一起推出。
演练
一个集成的基本结构
创建文件,把数据发送到签名目标,提交任务,然后通过 SSE 看着它完成。一共四步,只有第三步会因功能而不同。
1:创建文件并获取上传目标
curl -X POST https://api.euphonaai.com/v1/files \
-H "Authorization: Bearer $EUPHONA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"filename":"take-07.wav","bytes":58210044,"content_type":"audio/wav"}'
{
"id": "file_8Kd2mQ...",
"object": "file",
"status": "awaiting_upload",
"upload": { "protocol": "...", "url": "https://files.euphonaai.com/...", "expires_at": "..." }
}2:把数据发送到 files.,而不是发给我们
# 读取 upload.protocol:不要假定它的值。可续传机制是接口约定内部的
# 实现选择,可能在不升级主版本号的情况下改变,这正是它是一个字段而不是一段文档的原因。3:观察任务
curl -N https://api.euphonaai.com/v1/jobs/job_4Rb.../events \
-H "Authorization: Bearer $EUPHONA_API_KEY"
event: job.progress
data: {"status":"analyzing","stage":"analyze","progress":{"fraction":0.4}}
event: job.completed
data: {"status":"succeeded","result":{...},"metering":{"pool":"payg","charged_minutes":3.4}}4:以任务本身为准进行核对
curl https://api.euphonaai.com/v1/jobs/job_4Rb... \
-H "Authorization: Bearer $EUPHONA_API_KEY"
{"id":"job_4Rb...","status":"succeeded","result":{...},
"metering":{"pool":"payg","charged_minutes":3.4}}
# 任务资源是状态机当前的真实状态:无论流中显示了什么、漏掉了什么,
# 在据此行动之前都要重新读取它。SSE 连接断开后会带着
# Last-Event-ID 重新连接,不会丢失任何内容。参考
接口一览
所有功能共享的平台接口。每项功能只增加一个创建端点;它返回的任务会经过下面的一切:同样的生命周期、同样的事件、同样的报告。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /v1/files | 创建文件并获取签名、可续传的上传目标。 |
| GET | /v1/files/{id} | 导入状态、实测时长、采样率、声道数和格式。 |
| DELETE | /v1/files/{id} | 删除一个文件以及由它生成的内容;仍有任务在使用它时会被拒绝。 |
| GET | /v1/files/{id}/download | 一个会过期的签名下载 URL。 |
| GET | /v1/jobs | 统一的读取接口,按游标分页。 |
| GET | /v1/jobs/{id} | 状态、阶段、进度、计费和实测报告。 |
| POST | /v1/jobs/{id}/cancel | 取消;在处理(process)阶段开始之前不收费。 |
| POST | /v1/jobs/{id}/input | 回答处于 awaiting_input 状态的任务提出的问题,并让它继续。不计费。 |
| GET | /v1/jobs/{id}/events | 贯穿任务整个生命周期的服务器发送事件(SSE)。 |
| POST | /v1/projects | 把文件和任务分组;Track Graph 按项目划分。 |
| GET | /v1/projects/{id} | 一个项目及其文件和任务 ID。 |
| GET | /v1/usage | 额度状态和两个计量池。 |
| GET | /v1/webhook-endpoints | 此密钥所属模式下的 Webhook 端点。 |
| POST | /v1/webhook-endpoints | 注册一个 https URL;在保存任何内容之前,它要先应答一次签名质询。 |
| GET | /v1/webhook-endpoints/{id} | 单个端点。其密钥不会再次显示。 |
| PATCH | /v1/webhook-endpoints/{id} | 修改事件、描述或 URL;新的 URL 会重新验证。 |
| DELETE | /v1/webhook-endpoints/{id} | 删除;发往它但尚未投递的事件会被丢弃。 |
| POST | /v1/webhook-endpoints/{id}/rotate-secret | 新的签名密钥;旧密钥在过渡期内继续签名。 |
| GET | /v1/events | 三十天内的事件,以及每次投递的结果。 |
| GET | /v1/events/{id} | 按投递原样呈现的单个事件,以及它的投递记录。 |
Webhook
任务完成时收到通知
任务完成或等待你回答时发出的签名通知,集成无需一直保持流连接,也无需轮询。
- 事件
job.completed、job.failed、job.canceled和job.awaiting_input。每个都是一个事件,其data就是轮询返回的那个任务资源,分毫不差。- 工作区事件
- 当一个已存储的上传文件被定下可能被删除的日期时发送
file.expiring,data为该文件;当某个用量池达到其上限的 80% 或 100% 时发送usage.threshold。两者都会发往任一模式的端点,每个文件一次,或每个阈值和周期一次。 - 注册
- 需要具有
webhooks:manage权限范围的密钥,新密钥默认没有这项权限。URL 必须使用 https,且只能解析到公网地址;在保存任何内容之前,它必须以回传质询值的 2xx 响应应答签名质询。投递只通过 IPv4 发送,因此该主机必须有 IPv4 地址。 - 投递
- 至少投递一次,所以请按
Euphona-Event-Id去重。10 秒内未返回 2xx 的投递会以指数退避方式重试 24 小时,之后转为死信,并保留在GET /v1/events中。重定向一律不跟随,顺序也不保证:请以任务本身为准进行核对。 - 签名
Euphona-Signature包含一个时间戳,以及以端点密钥为键、对时间戳、一个点号和原始请求体计算的 HMAC-SHA256。请在解析前验证,用恒定时间比较,并拒绝与你的时钟相差超过五分钟的时间戳。- 来源地址
- 投递从
65.21.69.162发出。如果你的防火墙会过滤入站流量,请放行这些地址,但仍然要验证签名:请求来自哪个地址,并不能证明是谁发送的。
验证一次投递(Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
// 请原样传入收到的原始请求体:解析后再序列化的 JSON
// 不是被签名的那条消息。
export function verifyEuphonaSignature(rawBody, header, secret, now = Date.now() / 1000) {
const parts = header.split(",").map((part) => part.trim().split("="));
const t = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!Number.isFinite(t) || Math.abs(now - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return signatures.some((value) => {
const given = Buffer.from(value, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}运维
凌晨三点会咬人的那些事
这六种行为,值得在写集成之前读一读,而不是等它半夜把你叫醒之后。
- 密钥
eu_live_…和eu_test_…,归属于某个工作区,只在创建时显示一次,存储时经过哈希处理。轮换时可设置最长 30 天的重叠期,所以轮换不会造成服务中断。测试密钥在各自的每月上限内免费,并在独立的调度类别中运行;它们返回的是预览而不是母带,所以无需购买任何东西,就能端到端地搭建一个集成。- 错误
- 始终使用 RFC 9457
application/problem+json,带有稳定、机器可读的code、面向人的detail以及请求 ID。请根据code做分支判断,绝不要依据文字说明:文字会被本地化,而代码是接口约定。 - 幂等性
- 每个 POST 请求都带上
Idempotency-Key。相同的键配相同的请求体,只要它创建的资源还存在,就会重放最初的响应;相同的键配不同的请求体会被拒绝。因此,超时后的重试会返回最初的任务,而不是再启动一个要向你收费的任务。 - 限制
- 按密钥的滑动窗口限流,使用标准的
RateLimit-*响应头;按量付费为每分钟 60 个请求,承诺用量合同下更高。限流器只控制请求频率:吞吐量由你的额度和队列决定。 - 两个计量池,绝不合一
- API 密钥发起的任务计入按量付费池;Studio 会话发起的任务消耗套餐分钟数。两个池子谁都不会耗尽另一个,
GET /v1/usage会同时报告两者。消费上限默认开启。存储空间只有一个额度,而不是两个:用密钥上传的文件和 API 任务的结果,与 Studio 中的所有内容一起计入工作区套餐的存储空间额度(免费套餐为 2 GB),放不下的上传或任务会以storage_full被拒绝。 - Request-Id
- 每个响应都带有它,无论成功还是失败。在支持请求中提供它,就能端到端地定位问题:经过任务、经过每个阶段,一直到实际发生了什么。
code | title |
|---|---|
| unauthenticated | 需要身份验证 |
| invalid_ | API 密钥无效 |
| key_ | API 密钥已吊销 |
| insufficient_ | 权限范围不足 |
| email_ | 需要验证邮箱 |
| malformed_ | 请求格式错误 |
| validation_ | 请求校验失败 |
| unsupported_ | 不支持的媒体类型 |
| idempotency_ | 幂等键被用于不同的请求体 |
| not_ | 未找到 |
| job_ | 任务无法取消 |
| job_ | 任务没有在等待输入 |
| file_ | 文件尚未就绪 |
| file_ | 文件已过期 |
| file_ | 文件正在使用 |
| file_ | 文件因法律原因不可用 |
| unsupported_ | 不支持的音频格式 |
| unsupported_ | 不支持的采样率 |
| unsupported_ | 不支持的声道数 |
| file_ | 音频太长 |
| file_ | 音频太短 |
| file_ | 文件太大 |
| corrupt_ | 音频无法解码 |
| rate_ | 超出请求频率限制 |
| too_ | 进行中的任务过多 |
| quota_ | 超出额度 |
| spend_ | 已达到消费上限 |
| test_ | 已达到测试模式上限 |
| storage_ | 存储空间已满 |
| purchase_ | 需要购买 |
| payment_ | 需要付款方式 |
| format_ | 此套餐不包含该格式 |
| webhook_ | Webhook URL 必须使用 HTTPS |
| webhook_ | Webhook URL 必须可公开访问 |
| webhook_ | Webhook 所有权验证失败 |
| internal_ | 内部错误 |
| service_ | 服务不可用 |
| processing_ | 处理失败 |
| capacity_ | 算力不可用 |
| capability_ | 暂时停用 |
| workspace_ | 工作区已暂停 |
| uploads_ | 上传已暂停 |
| reauthentication_ | 确认是你本人 |