feat: add Gemini TTS models and service-account auth to media skills (#4377)

* feat: add opt-in Gemini 3.8 TTS to media skills

* feat: support Gemini service accounts and older TTS models

* fix(audio): isolate Gemini auth and narrow OAuth scope

* fix(audio): pin Gemini Python helpers to UTF-8
This commit is contained in:
James Russo
2026-09-24 02:21:23 +01:00
committed by GitHub
parent 86fa10ced4
commit 01601d1105
19 changed files with 948 additions and 21 deletions
+32 -2
View File
@@ -74,9 +74,37 @@ Run `npx hyperframes doctor` to check which local dependencies are installed.
The media workflows run `hyperframes auth status` before generation and tell
you which path they will use.
For Gemini narration, explicitly ask your agent to use Gemini TTS. The shared
audio workflow supports 3.8 Flash and Flash-Lite, 3.1 Flash Preview, and 2.5 Pro
and Flash Preview. Gemini remains an explicit choice; configuring credentials
does not change the automatic voice-provider order above.
Choose one authentication method:
- **API key:** set `GEMINI_API_KEY` or `GOOGLE_API_KEY` in your environment or
project `.env`. This path needs no Python dependencies.
- **Service account:** install `google-auth` and `requests` in your Python 3
environment, then set `GOOGLE_APPLICATION_CREDENTIALS` to the absolute path
of your service-account JSON file. Alternatively, inject the JSON through
`GCS_CREDS` from your secret manager. Set `GOOGLE_CLOUD_PROJECT` to the quota
project when it differs from the service account's project.
Credentials resolve in that order: Gemini key, Google key, service-account file,
then JSON environment variable. Unset API keys when choosing service-account
OAuth. The helper obtains a fresh token for each generation using the
`generative-language.retriever` scope. Keys and tokens
are never placed in compositions or saved audio metadata.
Both methods call the Gemini Developer Interactions API. Your project needs
access to that API and the requested model. Cloud Text-to-Speech and Vertex AI
have separate model availability. This helper accepts service-account JSON,
not user ADC files or metadata-server credentials.
See [Gemini voiceover](/prompting/media-and-audio#gemini-voiceover).
<Note>
`npx hyperframes tts` itself is the local Kokoro CLI. Hosted HeyGen and
ElevenLabs voices are selected by the bundled media workflow helpers, not by
ElevenLabs and Gemini voices are selected by the bundled media workflow helpers, not by
that command.
</Note>
@@ -112,7 +140,9 @@ to a shared space with `--space`.
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
| `ELEVENLABS_API_KEY` | ElevenLabs TTS, used when no HeyGen credential is present. |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Lyria music generation (and capture descriptions). |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Explicit Gemini TTS selection and Lyria music generation; capture descriptions use `GEMINI_API_KEY`. |
| `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. |
| `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT_ID` | Gemini OAuth quota project; first set value wins, then the service account project. |
| `OPENROUTER_API_KEY` | Capture descriptions; takes priority over Gemini for that step. |
## Related topics
+20
View File
@@ -38,6 +38,26 @@ agent choose.
- ❌ `add a voice`
- ✅ `warm, unhurried female narration of the quoted script` — tone and pace are what actually change the delivery
### Gemini voiceover
Choose Gemini when you want to direct a narrator's delivery with a description.
Configure [Gemini credentials](/guides/authentication#providers-used-by-agent-workflows),
then ask your agent:
> Use Gemini 3.8 Flash TTS with the Kore voice to narrate this script. Make the
> delivery warm and unhurried. Use the media-use audio engine, transcribe the
> generated audio, and sync the captions and visual reveals to the spoken words.
The shared audio workflow supports 3.8 Flash and Flash-Lite TTS,
3.1 Flash TTS Preview, and 2.5 Pro and Flash TTS Preview. Gemini is an explicit choice; it does not replace
the automatic provider order above. Keep the script separate from delivery
instructions. The helper uses structured style metadata for 3.8 and a delivery
prompt for older models; review the generated speech for accidental directions.
The agent saves the voiceover locally and obtains word timings through a
separate transcription pass. Check names, numbers, and pauses against the
audio before the final render. A new narration take needs new timings.
## Background music
Music resolves from a large catalog by mood. It should almost always sit *under*
+5 -5
View File
@@ -6,7 +6,7 @@
"files": 142
},
"faceless-explainer": {
"hash": "d3e32414b9e86678",
"hash": "995968cc3ba6faee",
"files": 27
},
"figma": {
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "d15857ed6f884a1f",
"files": 91
"hash": "b35de041b87e763d",
"files": 98
},
"motion-graphics": {
"hash": "ab9d2e9c205ce1b1",
@@ -66,11 +66,11 @@
"files": 169
},
"pr-to-video": {
"hash": "148de3a4692bc44d",
"hash": "b1473fb853c2ca2c",
"files": 33
},
"product-launch-video": {
"hash": "104014221c41ae13",
"hash": "6677e7785909bf98",
"files": 34
},
"remotion-to-hyperframes": {
@@ -173,6 +173,8 @@ function runGenerate(argv) {
: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
};
if (userVoice) request.voice = userVoice;
if (flag(argv, "tts-model", null)) request.tts_model = flag(argv, "tts-model", null);
if (flag(argv, "style", null)) request.style = flag(argv, "style", null);
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
@@ -14,9 +14,14 @@ node skills/media-use/audio/scripts/heygen-tts.mjs \
`narration.words.json` is already in the `[{ id, text, start, end }]` shape the captions pipeline consumes — no separate transcribe pass.
## Path B — ElevenLabs / Kokoro (TTS → Whisper)
## Path B — Gemini / ElevenLabs / Kokoro (TTS → transcription)
These providers don't return word data. Generate the audio, then transcribe:
These adapters supply audio without word data. The shared audio engine runs
transcription automatically when timings are absent. For Gemini, use the
request in [Text to speech](tts.md#gemini-narration), then consume
`audio_meta.json` → `voices[].words`.
For a standalone local Kokoro generation, generate the audio, then transcribe:
```bash
npx hyperframes tts script.txt --voice af_heart --output narration.wav
@@ -24,3 +29,7 @@ npx hyperframes transcribe narration.wav --model small.en # voice af_heart is
```
Whisper extracts precise word boundaries from the generated audio, so caption timing matches delivery without hand-tuning. Match `--model` to the voice's language (use `small.en` for `a`/`b` prefixes, `small --language <code>` otherwise). Then consume `transcript.json` via the caption references in `captions/`.
For Gemini, verify that transcription preserved the script, especially names,
numbers, and delivery pauses. If `words` is empty, resolve the transcription
failure before captioning. Generate and align again after changing the read.
+84 -6
View File
@@ -36,6 +36,10 @@ Use another voice only for a documented reason, and write the reason down.
## Available routes
Gemini is an explicit alternative to the automatic provider order below. A
request to use Gemini already chooses the provider; do not redirect that user
to HeyGen sign-in. Read the Gemini section for its credential requirement.
| Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
| ----- | ----------------- | ------------------------------------------- | ------------------------------------------- | ----------------------------------------- | -------------------- |
| 1 | HeyGen (Starfish) | `$HEYGEN_API_KEY` / `~/.heygen/credentials` | UUIDs from `GET /v3/voices?engine=starfish` | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
@@ -83,12 +87,86 @@ node skills/media-use/audio/scripts/heygen-tts.mjs --list # public starfish vo
## When to use which provider
| Goal | Use |
| --------------------------------------------------------- | --------------------------------------------------- |
| Best voice quality + word timestamps in one call | **HeyGen** |
| Drop-in cloud TTS, big voice catalog | **ElevenLabs** |
| Offline, no API key, fast iteration | **Kokoro** |
| Non-English multilingual with deterministic phonemization | **Kokoro** (`ef_dora`, `jf_alpha`, `zf_xiaobei`, …) |
| Goal | Use |
| --------------------------------------------------------- | --------------------------------------------------------- |
| Best voice quality + word timestamps in one call | **HeyGen** |
| Drop-in cloud TTS, big voice catalog | **ElevenLabs** |
| Offline, no API key, fast iteration | **Kokoro** |
| Directed delivery with Gemini prebuilt or custom voices | **Gemini** (explicit selection; transcription for timing) |
| Non-English multilingual with deterministic phonemization | **Kokoro** (`ef_dora`, `jf_alpha`, `zf_xiaobei`, …) |
## Gemini narration
Use the shared audio engine, not `hyperframes tts`. Authenticate with either:
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` (first set key wins).
- A service-account JSON file at `GOOGLE_APPLICATION_CREDENTIALS`, or injected
JSON in `GCS_CREDS`. Install `google-auth requests` in the Python 3 environment
used by the helper. A configured file takes precedence over injected JSON.
API keys take precedence over service accounts: unset both key variables to use
OAuth. Never put credentials in a request file or composition. The helper
obtains a fresh OAuth token for each generation with the
`generative-language.retriever` scope. The quota project resolves from
`GOOGLE_CLOUD_PROJECT`, then `GCLOUD_PROJECT_ID`, then the service-account JSON.
User ADC files and metadata-server authentication are not supported.
Both routes call the Gemini Developer Interactions API, not Cloud TTS or Vertex
AI. Those APIs have separate model catalogs and access requirements.
Save this as `audio_request.json` in the project:
```json
{
"provider": "gemini",
"tts_model": "gemini-3.8-flash-tts",
"voice": "Kore",
"lang": "en",
"style": "Warm, clear, conversational. Leave a short pause between sentences.",
"lines": [
{ "id": "intro", "text": "Every word has a moment. Let the picture follow the voice." }
],
"bgm": { "mode": "none" }
}
```
```bash
node <SKILL_DIR>/audio/scripts/audio.mjs \
--request ./audio_request.json --hyperframes . --out ./audio_meta.json --only tts
```
The engine saves `assets/voice/intro.wav`, measures its duration, and transcribes
it into `voices[].words` in `audio_meta.json`. Check that every requested line
has audio and nonempty word timings before building a captioned video. Review
the timings against the actual audio; transcription is estimated alignment,
not native TTS timestamps. Do not distribute words evenly across a clip.
- **Models:** `gemini-3.8-flash-tts` (default), `gemini-3.8-flash-lite-tts`,
`gemini-3.1-flash-tts-preview`, `gemini-2.5-pro-preview-tts`, and
`gemini-2.5-flash-preview-tts`. Use these exact Developer API IDs; Cloud TTS
aliases such as `gemini-2.5-flash-tts` are not accepted.
- **Voice:** `Kore` by default; pass another prebuilt voice or an existing custom
voice ID for 3.8. Older models require prebuilt voices. Creating or replicating
voices is outside this helper.
- **Delivery:** Put directions in `style`, not in spoken `text`. Each line can
override `style`. 3.8 uses structured annotations; older models receive a
delivery prompt before the transcript. Check that directions were not spoken.
Use style for pacing; numeric `speed` must be omitted or 1.
- **Audio:** 3.8 returns a complete WAV. Older models return mono 16-bit PCM,
which the helper wraps as WAV at the returned sample rate without resampling.
No generation calls run during playback or rendering.
- **Timing:** This adapter requests no native word timestamps. It uses the
existing transcription pass. `lang` selects transcription language; Gemini
infers speech language from the text.
- **Workflow adapters:** Product-launch, faceless-explainer, and PR-video audio
scripts accept `--provider gemini --voice Kore --tts-model gemini-3.8-flash-tts
--style "Warm and clear"`.
An API error is reported as a failed line; an explicitly chosen Gemini voice
never silently falls back to another provider. Check the engine's anomalies
and output metadata, not only its exit code.
API contract: [Google's speech generation guide](https://ai.google.dev/gemini-api/docs/speech-generation).
## ffmpeg requirement
+10 -3
View File
@@ -15,7 +15,7 @@
//
// ── audio_request.json (input) ────────────────────────────────────────────────
// {
// "provider": "auto", // auto|heygen|elevenlabs|kokoro (override: --provider)
// "provider": "auto", // auto|heygen|elevenlabs|kokoro|gemini (override: --provider)
// "lang": "en", "speed": 1.0,
// "lines": [ // one TTS unit each; id joins back to the caller's model
// { "id": "01", "text": "...", "sfx": ["whoosh", "ui click"] }
@@ -113,7 +113,6 @@ const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0;
// ── env + HeyGen availability (the single switch) ─────────────────────────────
loadEnvFromDir(hyperframesDir);
const heygenOK = heygenCredential() !== null;
const headers = heygenOK ? heygenAuthHeaders() : null;
// ── merge base: preserve sections not selected by --only ──────────────────────
const audioMeta = openAudioMeta(outPath);
@@ -153,6 +152,8 @@ if (only.has("tts") && lines.length) {
voiceId,
lang,
speed,
model: flag("tts-model", request.tts_model),
style: flag("style", line.style ?? request.style),
wavAbs: abs,
hyperframesDir,
});
@@ -210,7 +211,12 @@ if (only.has("bgm")) {
console.error(`· bgm: disabled`);
} else if (mode === "retrieve") {
try {
bgm = await retrieveBgm({ query: request.bgm?.query, headers, hyperframesDir, hasVoice });
bgm = await retrieveBgm({
query: request.bgm?.query,
headers: heygenAuthHeaders(),
hyperframesDir,
hasVoice,
});
if (bgm) {
bgmFields.bgm_provider = "heygen";
bgmFields.bgm_mode = "retrieve";
@@ -262,6 +268,7 @@ if (only.has("sfx")) {
.map((name) => ({ id: String(l.id), name: String(name).trim() }))
.filter((c) => c.name),
);
const headers = heygenOK && cues.length ? heygenAuthHeaders() : null;
const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
sfx = res.sfx;
anomalies.push(...res.anomalies);
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
// Exercise the actual engine and provider together; only the paid API and
// external audio tools are fixtures. This is not a live synthesis/render test.
for (const expired of [false, true]) {
for (const only of ["tts", "tts,bgm,sfx"]) {
test(`Gemini engine returns caption metadata with ${expired ? "expired" : "absent"} HeyGen credentials (${only})`, (t) => {
const dir = mkdtempSync(join(tmpdir(), "hf-gemini-pipeline-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const config = join(dir, "heygen");
mkdirSync(config);
if (expired)
writeFileSync(
join(config, "credentials"),
JSON.stringify({
oauth: { access_token: "expired-fixture", expires_at: "2000-01-01T00:00:00Z" },
}),
);
const bin = join(dir, "bin");
mkdirSync(bin);
const preload = join(dir, "fetch.mjs");
writeFileSync(
preload,
`
import assert from 'node:assert/strict';
globalThis.fetch = async (url, options) => {
assert.equal(url, 'https://generativelanguage.googleapis.com/v1beta/interactions');
const body = JSON.parse(options.body);
assert.equal(body.model, 'gemini-3.8-flash-lite-tts');
assert.equal(body.input[0].content[0].annotations[0].style, 'Line direction');
const bytes = Buffer.alloc(48);
bytes.write('RIFF'); bytes.write('WAVE', 8);
return Response.json({status:'completed', steps:[{type:'model_output', content:[
{type:'audio', mime_type:'audio/wav', data:bytes.toString('base64')}
]}]});
};
`,
);
writeFileSync(
join(bin, "npx"),
`#!${process.execPath}
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
assert.deepEqual(args.slice(0, 3), ['hyperframes', 'transcribe', 'assets/voice/intro.wav']);
assert.ok(fs.existsSync(args[2]));
assert.equal(args[args.indexOf('--model')+1], 'small.en');
fs.writeFileSync(path.join(args[args.indexOf('--dir')+1], 'transcript.json'), JSON.stringify([
{text:'Hello',start:0.1,end:0.4}, {text:'there.',start:0.5,end:0.9}
]));
`,
{ mode: 0o755 },
);
writeFileSync(join(bin, "ffprobe"), "#!/bin/sh\necho 1.25\n", { mode: 0o755 });
writeFileSync(join(dir, ".env"), "");
writeFileSync(
join(dir, "audio_request.json"),
JSON.stringify({
provider: "gemini",
tts_model: "gemini-3.8-flash-lite-tts",
style: "Global direction",
lines: [{ id: "intro", text: "Hello there.", style: "Line direction" }],
bgm: { mode: "none" },
}),
);
const result = spawnSync(
process.execPath,
[
"--import",
preload,
new URL("./audio.mjs", import.meta.url).pathname,
"--hyperframes",
dir,
"--only",
only,
],
{
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: config,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
GEMINI_API_KEY: "fixture-key",
PATH: `${bin}:${process.env.PATH}`,
},
},
);
assert.equal(result.status, 0, result.stderr);
const meta = JSON.parse(readFileSync(join(dir, "audio_meta.json"), "utf8"));
assert.equal(meta.tts_provider, "gemini");
assert.equal(meta.voice_id, "Kore");
assert.deepEqual(meta.voices, [
{
id: "intro",
path: "assets/voice/intro.wav",
duration_s: 1.25,
words: [
{ id: "w0", text: "Hello", start: 0.1, end: 0.4 },
{ id: "w1", text: "there.", start: 0.5, end: 0.9 },
],
},
]);
assert.equal(meta.total_duration_s, 1.25);
});
}
}
@@ -0,0 +1,43 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { pythonInvocation } from "./python.mjs";
export function geminiConfigured() {
return Boolean(
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
process.env.GCS_CREDS,
);
}
export function geminiAuth({ run = spawnSync } = {}) {
const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (key) return { headers: { "x-goog-api-key": key }, secret: key };
if (!geminiConfigured()) {
throw new Error(
"Gemini TTS needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)",
);
}
const { cmd, args } = pythonInvocation([
fileURLToPath(new URL("./gemini-auth.py", import.meta.url)),
]);
const result = run(cmd, args, { encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024 });
let data;
try {
data = JSON.parse(result.stdout);
} catch {
throw new Error(
"Gemini service-account authentication failed; check Python and install google-auth requests",
);
}
if (result.status !== 0 || !data.token) {
// Do not echo subprocess output: it could contain credentials.
throw new Error(
"Gemini service-account authentication failed; check service-account JSON, permissions, and Python packages google-auth requests",
);
}
const headers = { Authorization: `Bearer ${data.token}` };
if (data.project) headers["x-goog-user-project"] = data.project;
return { headers, secret: data.token };
}
@@ -0,0 +1,52 @@
"""Obtain Gemini OAuth headers without persisting credentials or tokens."""
import json
import os
import sys
# Keep the credential subprocess protocol UTF-8 on every platform.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
def authenticate():
try:
from google.auth.transport.requests import Request
from google.oauth2 import service_account
except ImportError:
return {"error": "Service-account auth needs Python packages: pip install google-auth requests"}
try:
path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if path:
with open(path, encoding="utf-8") as source:
info = json.load(source)
else:
info = json.loads(os.environ["GCS_CREDS"])
if info.get("type") != "service_account":
return {"error": "Gemini requires service-account JSON, not user ADC credentials"}
# Only exchange credentials with Google's OAuth server.
info["token_uri"] = "https://oauth2.googleapis.com/token"
credentials = service_account.Credentials.from_service_account_info(
info,
scopes=[
"https://www.googleapis.com/auth/generative-language.retriever",
],
)
credentials.refresh(Request())
project = (
os.environ.get("GOOGLE_CLOUD_PROJECT")
or os.environ.get("GCLOUD_PROJECT_ID")
or info.get("project_id")
)
return {"token": credentials.token, "project": project}
except Exception:
# Library exceptions can contain credential data. Never forward them.
return {"error": "Gemini service-account authentication failed; check credentials, permissions, and network access"}
if __name__ == "__main__":
result = authenticate()
print(json.dumps(result))
sys.exit(1 if "error" in result else 0)
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { pythonInvocation } from "./python.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { geminiAuth, geminiConfigured } from "./gemini-auth.mjs";
import { pickProvider } from "./tts.mjs";
function env(t) {
const saved = { ...process.env };
for (const key of [
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"GCS_CREDS",
])
delete process.env[key];
t.after(() => {
process.env = saved;
});
}
test("API keys take precedence over service accounts without invoking Python", (t) => {
env(t);
process.env.GEMINI_API_KEY = "gemini-key";
process.env.GOOGLE_API_KEY = "google-key";
process.env.GCS_CREDS = "private-json";
const run = () => assert.fail("must not spawn Python");
assert.deepEqual(geminiAuth({ run }).headers, { "x-goog-api-key": "gemini-key" });
delete process.env.GEMINI_API_KEY;
assert.deepEqual(geminiAuth({ run }).headers, { "x-goog-api-key": "google-key" });
});
test("service-account configuration selects Gemini and returns scoped helper headers", (t) => {
env(t);
assert.equal(geminiConfigured(), false);
assert.throws(() => geminiAuth(), /needs GEMINI_API_KEY/);
for (const key of ["GOOGLE_APPLICATION_CREDENTIALS", "GCS_CREDS"]) {
process.env[key] = "credential-source";
assert.equal(pickProvider("gemini"), "gemini");
const auth = geminiAuth({
run: (_, args, options) => {
assert.ok(args.at(-1).endsWith("gemini-auth.py"));
assert.ok(!args.includes("credential-source"));
assert.equal(options.timeout, 60000);
return {
status: 0,
stdout: JSON.stringify({ token: "access-token", project: "quota-project" }),
};
},
});
assert.deepEqual(auth.headers, {
Authorization: "Bearer access-token",
"x-goog-user-project": "quota-project",
});
delete process.env[key];
}
});
test("failed, malformed, or missing Python output cannot leak secrets or fall back", (t) => {
env(t);
process.env.GCS_CREDS = "private-json";
for (const result of [
{ status: 1, stdout: '{"error":"private-json"}', stderr: "private-json" },
{ status: null, stdout: "private-json", error: new Error("private-json") },
{ status: 0, stdout: "{}" },
]) {
assert.throws(
() => geminiAuth({ run: () => result }),
(error) => {
assert.match(error.message, /authentication failed/);
assert.ok(!error.message.includes("private-json"));
return true;
},
);
}
});
// Keep the Python credential-boundary suite reachable from the normal Node CI runner.
test("Python service-account boundary validates credentials, scope, transport and errors", () => {
const { cmd, args } = pythonInvocation([
fileURLToPath(new URL("./gemini-auth_test.py", import.meta.url)),
]);
const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000 });
assert.equal(result.status, 0, result.stderr || result.error?.message);
});
@@ -0,0 +1,90 @@
"""Credential-boundary tests; stub Google's transport, never use real secrets."""
import importlib.util
import json
import os
import sys
from pathlib import Path
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
# Keep the credential subprocess protocol UTF-8 on every platform.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
spec = importlib.util.spec_from_file_location("gemini_auth", Path(__file__).with_name("gemini-auth.py"))
auth = importlib.util.module_from_spec(spec)
spec.loader.exec_module(auth)
class AuthenticationTests(unittest.TestCase):
def setUp(self):
self.info = {"type": "service_account", "project_id": "account-project", "token_uri": "https://untrusted.invalid/token"}
self.credentials = Mock(token="fixture-token")
self.factory = Mock(return_value=self.credentials)
self.request = Mock(return_value=object())
transport = types.ModuleType("google.auth.transport.requests")
transport.Request = self.request
oauth = types.ModuleType("google.oauth2")
oauth.service_account = types.SimpleNamespace(Credentials=types.SimpleNamespace(from_service_account_info=self.factory))
modules = {name: types.ModuleType(name) for name in ("google", "google.auth", "google.auth.transport")}
modules.update({"google.auth.transport.requests": transport, "google.oauth2": oauth})
self.addCleanup(patch.stopall)
patch.dict("sys.modules", modules).start()
patch.dict(os.environ, {"GCS_CREDS": json.dumps(self.info)}, clear=True).start()
def test_scope_endpoint_and_refresh(self):
self.assertEqual(auth.authenticate(), {"token": "fixture-token", "project": "account-project"})
info = self.factory.call_args.args[0]
self.assertEqual(info["token_uri"], "https://oauth2.googleapis.com/token")
self.assertEqual(self.factory.call_args.kwargs["scopes"], ["https://www.googleapis.com/auth/generative-language.retriever"])
self.credentials.refresh.assert_called_once_with(self.request.return_value)
def test_file_takes_precedence_over_json_environment(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "service.json"
path.write_text(json.dumps({**self.info, "project_id": "file-project"}), encoding="utf-8")
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(path)
os.environ["GCS_CREDS"] = "invalid-json"
self.assertEqual(auth.authenticate()["project"], "file-project")
def test_quota_project_precedence(self):
os.environ["GCLOUD_PROJECT_ID"] = "alias-project"
self.assertEqual(auth.authenticate()["project"], "alias-project")
os.environ["GOOGLE_CLOUD_PROJECT"] = "explicit-project"
self.assertEqual(auth.authenticate()["project"], "explicit-project")
def test_user_adc_is_rejected_before_exchange(self):
os.environ["GCS_CREDS"] = json.dumps({"type": "authorized_user"})
self.assertIn("service-account JSON", auth.authenticate()["error"])
self.factory.assert_not_called()
def test_invalid_json_or_missing_file_does_not_fall_back(self):
os.environ["GCS_CREDS"] = "private-invalid-json"
self.assertIn("error", auth.authenticate())
os.environ["GCS_CREDS"] = json.dumps(self.info)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/missing/service-account-fixture.json"
self.assertIn("error", auth.authenticate())
self.factory.assert_not_called()
def test_library_errors_are_redacted(self):
for boundary in (self.factory, self.credentials.refresh):
with self.subTest(boundary=boundary):
boundary.side_effect = RuntimeError("private-key-and-token")
result = auth.authenticate()
self.assertIn("error", result)
self.assertNotIn("private-key-and-token", json.dumps(result))
boundary.side_effect = None
def test_missing_dependency_has_actionable_error(self):
with patch.dict("sys.modules", {"google.auth.transport.requests": None}):
self.assertIn("pip install google-auth requests", auth.authenticate()["error"])
self.factory.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,122 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { geminiAuth } from "./gemini-auth.mjs";
export const GEMINI_TTS_MODEL = "gemini-3.8-flash-tts";
export const GEMINI_TTS_MODELS = [
GEMINI_TTS_MODEL,
"gemini-3.8-flash-lite-tts",
"gemini-3.1-flash-tts-preview",
"gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-preview-tts",
];
// 3.8 returns WAV; older models return PCM that we wrap without resampling.
// The shared engine transcribes the saved audio for word timings.
export async function synthesizeGemini(
{ text, voiceId = "Kore", model = GEMINI_TTS_MODEL, style, speed = 1, wavAbs },
{ fetchImpl = fetch, authenticate = geminiAuth } = {},
) {
let secret;
try {
if (!GEMINI_TTS_MODELS.includes(model)) {
throw new Error(`Unsupported Gemini TTS model: ${model}`);
}
if (speed !== 1) throw new Error("Gemini TTS uses style for pacing; omit speed or use 1");
const modern = model.startsWith("gemini-3.8-");
if (!modern && /^(voice_|voicekey_)/.test(voiceId)) {
throw new Error(
"Custom Gemini voices require a 3.8 TTS model; use a prebuilt voice with older models",
);
}
const auth = authenticate();
secret = auth.secret;
const content = {
type: "text",
text:
!modern && style ? `Read the following text with this delivery: ${style}\n\n${text}` : text,
};
if (modern && style) content.annotations = [{ type: "speech_metadata", style }];
const response = await fetchImpl(
"https://generativelanguage.googleapis.com/v1beta/interactions",
{
method: "POST",
headers: { "Content-Type": "application/json", ...auth.headers },
signal: AbortSignal.timeout(120_000),
body: JSON.stringify({
model,
input: [{ type: "user_input", content: [content] }],
response_format: modern ? { type: "audio", mime_type: "audio/wav" } : { type: "audio" },
generation_config: { speech_config: [{ voice: voiceId }] },
store: false,
}),
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(`Gemini TTS HTTP ${response.status}: ${detail}`);
}
const payload = await response.json();
if (payload.status !== "completed") {
throw new Error(`Gemini TTS did not complete (${payload.status ?? "missing status"})`);
}
const audio = (payload.steps ?? [])
.filter((step) => step.type === "model_output")
.flatMap((step) => step.content ?? [])
.filter((part) => part.type === "audio");
if (audio.length !== 1 || !audio[0].data) {
throw new Error("Gemini TTS returned no single audio block");
}
let bytes = Buffer.from(audio[0].data, "base64");
if (!modern && /^audio\/l16(?:;|$)/i.test(audio[0].mime_type ?? "")) {
bytes = pcmToWav(bytes, audio[0].mime_type);
} else if (audio[0].mime_type !== "audio/wav") {
throw new Error("Gemini TTS returned an unsupported audio format");
}
if (
bytes.length <= 44 ||
bytes.toString("ascii", 0, 4) !== "RIFF" ||
bytes.toString("ascii", 8, 12) !== "WAVE"
) {
throw new Error("Gemini TTS returned invalid WAV audio");
}
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);
return { ok: true, words: null };
} catch (error) {
// Error responses must never echo the credential into logs or metadata.
const message = String(error?.message ?? error);
return {
ok: false,
words: null,
error: secret ? message.split(secret).join("[redacted]") : message,
};
}
}
function pcmToWav(pcm, mime) {
const rate = Number(/(?:^|;)\s*rate=(\d+)(?:;|$)/i.exec(mime)?.[1]);
const channels = /(?:^|;)\s*channels=([^;]+)/i.exec(mime)?.[1];
const codec = /(?:^|;)\s*codec=([^;]+)/i.exec(mime)?.[1];
if ((channels && channels.trim() !== "1") || (codec && codec.trim() !== "pcm")) {
throw new Error("Gemini TTS returned unsupported PCM channels or codec");
}
if (!Number.isInteger(rate) || rate < 8000 || rate > 96000 || !pcm.length || pcm.length % 2) {
throw new Error("Gemini TTS returned invalid PCM audio or sample rate");
}
const header = Buffer.alloc(44);
header.write("RIFF");
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVEfmt ", 8);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(rate, 24);
header.writeUInt32LE(rate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
@@ -0,0 +1,243 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { synthesizeGemini, GEMINI_TTS_MODEL } from "./gemini-tts.mjs";
import { pickProvider, resolveVoiceId, synthesizeOne } from "./tts.mjs";
function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), "gemini-tts-"));
const saved = { ...process.env };
process.env.GEMINI_API_KEY = "test-gemini-key";
delete process.env.GOOGLE_API_KEY;
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
delete process.env.GCS_CREDS;
t.after(() => {
process.env = saved;
rmSync(dir, { recursive: true, force: true });
});
const wav = Buffer.alloc(48);
wav.write("RIFF");
wav.writeUInt32LE(40, 4);
wav.write("WAVEfmt ", 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(24000, 24);
wav.writeUInt32LE(48000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write("data", 36);
wav.writeUInt32LE(4, 40);
const audio = { type: "audio", mime_type: "audio/wav", data: wav.toString("base64") };
const payload = { status: "completed", steps: [{ type: "model_output", content: [audio] }] };
return {
wav,
audio,
payload,
args: { text: "Hello there.", voiceId: "Kore", wavAbs: join(dir, "voice", "one.wav") },
};
}
test("Gemini preserves verbatim text, directs style separately, saves WAV and requests transcription", async (t) => {
const { wav, payload, args } = fixture(t);
t.mock.method(globalThis, "fetch", async (url, options) => {
assert.equal(url, "https://generativelanguage.googleapis.com/v1beta/interactions");
assert.equal(options.headers["x-goog-api-key"], "test-gemini-key");
const body = JSON.parse(options.body);
assert.equal(body.model, GEMINI_TTS_MODEL);
assert.equal(body.store, false);
assert.deepEqual(body.input[0].content, [
{
type: "text",
text: args.text,
annotations: [{ type: "speech_metadata", style: "Warm and calm" }],
},
]);
assert.deepEqual(body.generation_config.speech_config, [{ voice: "Kore" }]);
assert.equal(body.response_format.mime_type, "audio/wav");
return Response.json(payload);
});
const result = await synthesizeOne({ ...args, provider: "gemini", style: "Warm and calm" });
assert.deepEqual(result, { ok: true, words: null });
assert.deepEqual(readFileSync(args.wavAbs), wav);
});
test("Flash-Lite and GOOGLE_API_KEY work without a Gemini key", async (t) => {
const { payload, args } = fixture(t);
delete process.env.GEMINI_API_KEY;
process.env.GOOGLE_API_KEY = "test-google-key";
assert.equal(pickProvider("gemini"), "gemini");
assert.equal(await resolveVoiceId({ provider: "gemini" }), "Kore");
const result = await synthesizeGemini(
{ ...args, model: "gemini-3.8-flash-lite-tts" },
{
fetchImpl: async (_, options) => {
assert.equal(options.headers["x-goog-api-key"], "test-google-key");
const body = JSON.parse(options.body);
assert.equal(body.model, "gemini-3.8-flash-lite-tts");
assert.equal(body.input[0].content[0].annotations, undefined);
return Response.json(payload);
},
},
);
assert.equal(result.ok, true);
});
test("Gemini remains opt-in even with a key and preserves explicitly chosen voices", async (t) => {
fixture(t);
assert.notEqual(pickProvider(), "gemini");
assert.equal(
await resolveVoiceId({ provider: "gemini", userVoice: "voice_custom" }),
"voice_custom",
);
delete process.env.GEMINI_API_KEY;
assert.throws(() => pickProvider("gemini"), /GEMINI_API_KEY or GOOGLE_API_KEY/);
});
test("invalid config fails before spending a generation request", async (t) => {
const { args } = fixture(t);
const deps = { fetchImpl: () => assert.fail("must not call the API") };
for (const change of [{ model: "gemini-2.5-flash" }, { speed: 1.2 }]) {
const result = await synthesizeGemini({ ...args, ...change }, deps);
assert.equal(result.ok, false);
assert.ok(result.error);
}
delete process.env.GEMINI_API_KEY;
assert.match((await synthesizeGemini(args, deps)).error, /needs GEMINI_API_KEY/);
assert.equal(existsSync(args.wavAbs), false);
});
test("HTTP and network failures stay actionable without leaking the key", async (t) => {
const { args } = fixture(t);
const result = await synthesizeGemini(args, {
fetchImpl: async () => new Response("quota exceeded test-gemini-key", { status: 429 }),
});
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 429: quota exceeded/);
assert.ok(!result.error.includes("test-gemini-key"));
const timeout = await synthesizeGemini(args, {
fetchImpl: async () => {
throw new Error("timed out");
},
});
assert.match(timeout.error, /timed out/);
assert.equal(existsSync(args.wavAbs), false);
});
test("incomplete, missing, raw PCM and malformed audio cannot become a successful WAV", async (t) => {
const { args, payload, audio } = fixture(t);
const cases = [
{ ...payload, status: "incomplete" },
{ status: "completed", steps: [] },
...[{ ...audio, mime_type: "audio/l16" }, { ...audio, data: "not audio" }, audio].map(
(part, i) => ({
status: "completed",
steps: [{ type: "model_output", content: i === 2 ? [part, part] : [part] }],
}),
),
];
for (const body of cases) {
const result = await synthesizeGemini(args, { fetchImpl: async () => Response.json(body) });
assert.equal(result.ok, false);
assert.equal(existsSync(args.wavAbs), false);
}
});
for (const model of [
"gemini-3.1-flash-tts-preview",
"gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-preview-tts",
]) {
test(`${model} uses legacy delivery prompts and wraps PCM as mono WAV`, async (t) => {
const { args, wav } = fixture(t);
const pcm = wav.subarray(44);
const result = await synthesizeGemini(
{ ...args, model, style: "Warm and clear" },
{
fetchImpl: async (_, options) => {
const body = JSON.parse(options.body);
assert.deepEqual(body.response_format, { type: "audio" });
assert.equal(body.input[0].content[0].annotations, undefined);
assert.ok(body.input[0].content[0].text.endsWith(args.text));
assert.ok(body.input[0].content[0].text.includes("Warm and clear"));
return Response.json({
status: "completed",
steps: [
{
type: "model_output",
content: [
{
type: "audio",
mime_type: "audio/L16;codec=pcm;rate=24000",
data: pcm.toString("base64"),
},
],
},
],
});
},
},
);
assert.equal(result.ok, true, result.error);
assert.deepEqual(readFileSync(args.wavAbs), wav);
});
}
test("service-account bearer and quota project reach synthesis, and token errors are redacted", async (t) => {
const { args, payload } = fixture(t);
const authenticate = () => ({
headers: { Authorization: "Bearer secret-token", "x-goog-user-project": "test-project" },
secret: "secret-token",
});
const ok = await synthesizeGemini(args, {
authenticate,
fetchImpl: async (_, options) => {
assert.equal(options.headers.Authorization, "Bearer secret-token");
assert.equal(options.headers["x-goog-user-project"], "test-project");
assert.equal(options.headers["x-goog-api-key"], undefined);
return Response.json(payload);
},
});
assert.equal(ok.ok, true);
const failed = await synthesizeGemini(args, {
authenticate,
fetchImpl: async () => new Response("denied secret-token", { status: 403 }),
});
assert.match(failed.error, /HTTP 403/);
assert.ok(!failed.error.includes("secret-token"));
});
test("older models reject unsupported PCM and custom voices", async (t) => {
const { args } = fixture(t);
for (const mime of [
"audio/l16",
"audio/l16;rate=0",
"audio/l16;rate=24000;channels=2",
"audio/l16;rate=24000;codec=other",
]) {
const result = await synthesizeGemini(
{ ...args, model: "gemini-3.1-flash-tts-preview" },
{
fetchImpl: async () =>
Response.json({
status: "completed",
steps: [
{
type: "model_output",
content: [{ type: "audio", mime_type: mime, data: "AAAAAA==" }],
},
],
}),
},
);
assert.equal(result.ok, false);
assert.equal(existsSync(args.wavAbs), false);
}
const result = await synthesizeGemini(
{ ...args, model: "gemini-2.5-pro-preview-tts", voiceId: "voice_custom" },
{ fetchImpl: () => assert.fail("must not generate") },
);
assert.match(result.error, /Custom Gemini voices require/);
});
+13 -2
View File
@@ -20,6 +20,8 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
import { pythonInvocation } from "./python.mjs";
import { synthesizeGemini } from "./gemini-tts.mjs";
import { geminiConfigured } from "./gemini-auth.mjs";
// ── provider detection ────────────────────────────────────────────────────────
export function heygenAvailable() {
@@ -37,8 +39,12 @@ export function elevenlabsAvailable() {
// First available provider wins; an explicit choice is honored (and validated).
export function pickProvider(userProvider) {
if (userProvider) {
if (!["heygen", "elevenlabs", "kokoro"].includes(userProvider))
throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro)`);
if (!["heygen", "elevenlabs", "kokoro", "gemini"].includes(userProvider))
throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro | gemini)`);
if (userProvider === "gemini" && !geminiConfigured())
throw new Error(
"provider=gemini needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)",
);
if (userProvider === "heygen" && !heygenAvailable())
throw new Error(
"provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)",
@@ -56,6 +62,7 @@ export function pickProvider(userProvider) {
// their own defaults.
export async function resolveVoiceId({ provider, userVoice, lang = "en" }) {
if (userVoice) return userVoice;
if (provider === "gemini") return "Kore";
if (provider === "elevenlabs") return "21m00Tcm4TlvDq8ikWAM"; // Rachel
if (provider === "kokoro") {
if (lang === "en") return "am_michael";
@@ -248,10 +255,14 @@ export async function synthesizeOne({
voiceId,
lang = "en",
speed = 1.0,
model,
style,
wavAbs,
hyperframesDir,
}) {
if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
if (provider === "gemini")
return synthesizeGemini({ text, voiceId, model, style, speed, wavAbs });
if (provider === "elevenlabs") {
// The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav)
// and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir,
+2 -1
View File
@@ -9,7 +9,8 @@ shot), use the shared engine at `audio/scripts/audio.mjs`. It takes a neutral
node <SKILL_DIR>/audio/scripts/audio.mjs --request ./audio_request.json --out ./audio_meta.json
```
- **Request** `{ provider?, lang?, speed?, lines: [{ id, text, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
- **Request** `{ provider?, voice?, lang?, speed?, tts_model?, style?, lines: [{ id, text, style?, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
- **Gemini narration** is opt-in with `provider: "gemini"` and an API key or service-account credentials. `tts_model` defaults to `gemini-3.8-flash-tts`; 3.8 Flash-Lite, 3.1 Flash Preview, and 2.5 Pro/Flash Preview TTS are also supported. `style` directs delivery; a line's style overrides the request's style. CLI overrides: `--tts-model`, `--style`. See `audio/references/tts.md` for a complete request. The existing automatic provider order is unchanged.
- **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`.
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work.
- If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render.
+2
View File
@@ -173,6 +173,8 @@ function runGenerate(argv) {
: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
};
if (userVoice) request.voice = userVoice;
if (flag(argv, "tts-model", null)) request.tts_model = flag(argv, "tts-model", null);
if (flag(argv, "style", null)) request.style = flag(argv, "style", null);
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
@@ -172,6 +172,8 @@ function runGenerate(argv) {
: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
};
if (userVoice) request.voice = userVoice;
if (flag(argv, "tts-model", null)) request.tts_model = flag(argv, "tts-model", null);
if (flag(argv, "style", null)) request.style = flag(argv, "style", null);
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
@@ -34,6 +34,22 @@ test("passes --provider to the shared audio engine", () => {
assert.equal(runAudio({ args: ["--provider", "kokoro"] }).provider, "kokoro");
});
test("passes Gemini model and delivery style through the workflow adapter", () => {
const request = runAudio({
args: [
"--provider",
"gemini",
"--tts-model",
"gemini-3.8-flash-lite-tts",
"--style",
"Warm and calm",
],
});
assert.equal(request.provider, "gemini");
assert.equal(request.tts_model, "gemini-3.8-flash-lite-tts");
assert.equal(request.style, "Warm and calm");
});
test("uses HF_TTS_PROVIDER when --provider is omitted", () => {
assert.equal(runAudio({ env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, "elevenlabs");
});