Skills › HyperFrames › media-use › lyria-recipe.py
lyria-recipe.py
136 lines · skills/media-use/audio/scripts/lyria-recipe.py at a595755
1#!/usr/bin/env python3
2"""Generate BGM using Google Lyria RealTime API.
4Usage:
5 python lyria-recipe.py --output <path> --duration <seconds> [tuning flags]
7Requires:
8 $GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases).
9 pip install google-genai python-dotenv. audio.mjs Step 4b installs these on
10 demand when a key is set but google.genai is not importable; if that install
11 fails it falls back to local MusicGen rather than leaving the video with no BGM.
12"""
14from __future__ import annotations
16import argparse
17import asyncio
18import os
19import sys
20import wave
21from pathlib import Path
23# Windows sizes stdio to the ANSI code page (cp1252). These scripts emit UTF-8 on
24# every platform; say so rather than depending on the console's code page. Carry
25# `errors` across: reconfigure() resets it to "strict", and CPython deliberately gives
26# stderr "backslashreplace" so the diagnostic path can never itself raise.
27for _stream in (sys.stdout, sys.stderr):
28 if hasattr(_stream, "reconfigure"):
29 _stream.reconfigure(encoding="utf-8", errors=_stream.errors)
31DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads"
32SAMPLE_RATE = 48000
33CHANNELS = 2
34SAMPLE_WIDTH = 2 # 16-bit
37def parse_args() -> argparse.Namespace:
38 p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.")
39 p.add_argument("--output", required=True, help="Output WAV path.")
40 p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.")
41 p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.")
42 p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).")
43 p.add_argument("--bpm", type=int, default=110)
44 p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.")
45 p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.")
46 p.add_argument(
47 "--scale",
48 default="MAJOR",
49 help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.",
50 )
51 return p.parse_args()
54async def generate_bgm(args: argparse.Namespace) -> dict:
55 from google import genai
56 from google.genai import types
58 api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or ""
59 if not api_key:
60 raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.")
62 client = genai.Client(
63 api_key=api_key,
64 http_options={"api_version": "v1alpha"},
65 )
67 out_path = Path(args.output)
68 out_path.parent.mkdir(parents=True, exist_ok=True)
70 target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
72 cfg: dict = {"bpm": args.bpm, "temperature": 1.0}
73 if args.density is not None:
74 cfg["density"] = args.density
75 if args.brightness is not None:
76 cfg["brightness"] = args.brightness
77 if args.scale:
78 scale_enum = getattr(types.Scale, args.scale, None)
79 if scale_enum:
80 cfg["scale"] = scale_enum
82 prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)]
83 if args.negative_prompt:
84 prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0))
86 buf = bytearray()
87 timeout = args.duration + 8
89 async with client.aio.live.music.connect(
90 model="models/lyria-realtime-exp",
91 ) as session:
92 await session.set_weighted_prompts(prompts=prompts)
93 await session.set_music_generation_config(
94 config=types.LiveMusicGenerationConfig(**cfg),
95 )
96 await session.play()
98 async def collect():
99 while len(buf) < target_bytes:
100 async for msg in session.receive():
101 sc = msg.server_content
102 if sc and sc.audio_chunks:
103 for chunk in sc.audio_chunks:
104 buf.extend(chunk.data)
105 if len(buf) >= target_bytes:
106 return
107 await asyncio.sleep(1e-6)
109 try:
110 await asyncio.wait_for(collect(), timeout=timeout)
111 except TimeoutError:
112 print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr)
114 audio = bytes(buf[:target_bytes])
115 with wave.open(str(out_path), "wb") as wf:
116 wf.setnchannels(CHANNELS)
117 wf.setsampwidth(SAMPLE_WIDTH)
118 wf.setframerate(SAMPLE_RATE)
119 wf.writeframes(audio)
121 actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
122 print(f"BGM: {out_path} ({actual_duration:.2f}s)")
123 return {"file": str(out_path), "duration_sec": round(actual_duration, 2)}
126def main() -> None:
127 args = parse_args()
128 try:
129 asyncio.run(generate_bgm(args))
130 except RuntimeError as exc:
131 print(f"BGM generation failed: {exc}", file=sys.stderr)
132 sys.exit(1)
135if __name__ == "__main__":
136 main()