makepad/libs/ai/models/sing/tools/song_producer.py
Admin 2b01ea1a14 ai sing, ai-hub, tools: the song producer speaks the fleet's known-node TLS and credential proofs; the installer lets the node updater read only the node credential
song_producer.py: an https node is reached over TLS whose certificate is recorded in ~/.makepad/ai-hub/known_nodes on first contact (a change is warned about, logged to known_nodes.log, recorded, and the request goes on, as in the Rust clients), and each request carries MKC2 <id>.<role>.<expiry>.<HMAC(secret, certificate sha256)> from ~/.makepad/ai-hub/client.credential, never the credential itself. http nodes (not yet on the fleet TLS) get no credential. fleet_auth gains a fixed proof test vector that the Python implementation reproduces.

aihub-node-install-services.ps1: after the node is up, NT SERVICE\MakepadTunnel may read <cache>\fleet\node.credential and tls\tls-fingerprint.txt (the node updater authenticates to /health with the node's own credential) and nothing else in the fleet directory (not fleet.key, not the TLS key).

Checked: cargo test --release -p makepad-ai-hub --lib fleet_auth (2 pass incl. the test vector); the Python fleet_authorization gives the same string for the vector; known_node records, then warns, logs and replaces on a change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:38:57 +02:00

342 lines
17 KiB
Python

#!/usr/bin/env python3
"""Sung-lyric training data: original lyrics -> songs on one fleet node.
Writes original lyrics (a procedural lyric grammar over a phonetically varied
word bank) and a music description, asks one AI Hub node for --takes songs
(seeds) of each with the music model, separates their vocals in a batch, and
saves <inbox>/<group>-t<k>.wav, .vocals.wav.v and .json (lyrics, caption,
seed, model, node, group, takes). Standard library only. One job at a time;
respects the node's admission (a refused or paused node is retried later,
never forced) and keeps the job lease alive while it runs.
song_producer.py --node http://10.0.0.123:8123 --inbox DIR [--takes 2] [--batch 4] [--model ace-step-1.5-xl]
"""
import argparse
import array
import base64
import hashlib
import hmac
import json
import pathlib
import random
import time
import http.client
import os
import ssl
import sys
import urllib.parse
import uuid
MODEL = "minimax-music3-q4" # --model overrides (e.g. ace-step-1.5-xl)
# A word bank chosen for phonetic coverage: every English vowel (short, long,
# diphthongs, r-coloured) and consonant, and clusters (str, spl, nd, mps...).
NOUNS = """heart night light fire river street city ocean shadow window morning summer winter
rain thunder silver mirror garden border story letter feather mountain valley engine
stranger dream echo moment promise season harbour candle velvet thread question answer
treasure pleasure measure freedom kingdom rhythm voice choice journey highway wonder
paper station bridge island signal crystal planet circus forest desert frozen lake
spring stream splinter strength glimpse twelfth sixths breath truth youth judge church
orange purple yellow lightning anthem garage vision azure jewel boy toy coin joy noise
cloud crowd mouth south house tower flower hour earth bird word world circle girl""".split()
VERBS = """run fly burn shine break hold fall rise call wait sing dance breathe dream fade
glow shake chase follow wander whisper scream remember forget believe carry gather
stumble tremble crumble sparkle shatter scatter splash stretch squeeze twist launch
jump climb drift sail turn learn yearn ache wake choose lose move prove leave weave""".split()
ADJS = """bright dark cold warm wild quiet loud golden broken empty endless hollow gentle
heavy lonely restless silent tender burning frozen open distant crimson faded sweet
strange brave careless thirsty lucky smooth rough sharp blue green grey high low""".split()
PLACES = ["in the {adj} {noun}", "across the {noun}", "under the {adj} sky", "through the {noun}",
"beyond the {noun}", "beneath the {noun}", "along the {adj} {noun}", "inside the {noun}"]
LINES = [
"I {verb} {place}",
"we {verb} like a {adj} {noun}",
"you {verb} and I {verb}",
"the {noun} will {verb} {place}",
"{adj} {noun}, {adj} {noun}",
"hold the {noun} and {verb} with me",
"every {noun} is {adj} tonight",
"don't let the {noun} {verb}",
"I can hear the {noun} {verb}",
"we were {adj} and {adj}",
"tell me how the {noun} can {verb}",
"my {noun} is a {adj} {noun}",
]
GENRES = ["pop", "synth pop", "indie pop", "soft rock", "acoustic pop", "r&b", "folk pop", "electro pop", "ballad"]
# Every major and minor key, so the sung range moves from set to set.
TONICS = ["C", "C sharp", "D", "E flat", "E", "F", "F sharp", "G", "A flat", "A", "B flat", "B"]
KEYS = [f"{t} {m}" for t in TONICS for m in ("major", "minor")]
VOICE_MAIN = "female pop lead vocal, clear diction, upfront dry vocals, every word clearly sung"
# Every song asks for one clear solo voice (style "b"; "a", a sparse
# arrangement alone, kept more ad-libs and backing vocals).
STYLES = {
"b": "solo lead vocal only, no backing vocals, no harmonies, no ad-libs, sparse arrangement",
}
VOICE_OTHER = ["male pop lead vocal, clear diction, upfront vocals", "soft female vocal, clear diction",
"warm male tenor vocal, clear diction"]
def line(rng):
t = rng.choice(LINES)
out = ""
while "{" in t:
place = rng.choice(PLACES).format(adj=rng.choice(ADJS), noun=rng.choice(NOUNS))
t = t.replace("{place}", place, 1)
t = t.replace("{verb}", rng.choice(VERBS), 1).replace("{adj}", rng.choice(ADJS), 1).replace("{noun}", rng.choice(NOUNS), 1)
out = t
out = out or t
# "a" before a vowel sound.
words = out.split(" ")
for i in range(len(words) - 1):
if words[i] == "a" and words[i + 1][:1] in "aeiou":
words[i] = "an"
return " ".join(words)
def song(rng):
verse = lambda: [line(rng) for _ in range(4)]
chorus = [line(rng) for _ in range(4)]
parts = ["[Verse]"] + verse() + ["", "[Chorus]"] + chorus
lyrics = "\n".join(parts)
voice = VOICE_MAIN if rng.random() < 0.8 else rng.choice(VOICE_OTHER)
bpm = rng.randrange(80, 128)
style = rng.choice(sorted(STYLES))
caption = f"{rng.choice(GENRES)}, {bpm} BPM, {rng.choice(KEYS)}, {voice}, {STYLES[style]}"
return lyrics, caption, voice, style
HUB = pathlib.Path(os.path.expanduser("~/.makepad/ai-hub"))
def fleet_authorization(fingerprint_hex):
"""MKC2 proof for a node presenting this certificate (tools/aihub-fleet.md):
the credential itself (client.credential) never leaves this machine."""
tag, ident, role, expiry, secret = (HUB / "client.credential").read_text().strip().split(".")
if tag != "mkc2":
raise RuntimeError("client.credential is not an mkc2 fleet credential")
proof = hmac.new(bytes.fromhex(secret), ("mkfleet2 proof|" + fingerprint_hex).encode(), hashlib.sha256).hexdigest()
return f"MKC2 {ident}.{role}.{expiry}.{proof}"
def known_node(host_port, fingerprint_hex):
"""ssh-style known nodes (~/.makepad/ai-hub/known_nodes, shared with the
Rust clients): record on first contact; a changed certificate is warned
about loudly, logged, recorded, and the request goes on."""
path = HUB / "known_nodes"
lines = path.read_text().splitlines() if path.exists() else []
old = next((l.split()[1] for l in lines if l.split()[:1] == [host_port]), None)
if old == fingerprint_hex:
return
if old is not None:
print(f"@@@ WARNING: THE TLS CERTIFICATE OF {host_port} HAS CHANGED\n@@@ was {old}\n@@@ now {fingerprint_hex}", file=sys.stderr, flush=True)
with open(HUB / "known_nodes.log", "a") as log:
log.write(f"{int(time.time())} {host_port} changed {old} -> {fingerprint_hex}\n")
lines = [l for l in lines if l.split()[:1] != [host_port]] + [f"{host_port} {fingerprint_hex}"]
tmp = path.with_suffix(".tmp")
tmp.write_text("\n".join(lines) + "\n")
tmp.replace(path)
def fetch(base, path, body=None, timeout=15):
"""One request to a node. https://host:port is a fleet node: TLS whose
certificate is checked against (and recorded in) known_nodes, then the
credential proof bound to that certificate. http://host:port is only for
a node not yet on the fleet TLS (sent no credential)."""
u = urllib.parse.urlsplit(base)
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if u.scheme == "https":
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
conn = http.client.HTTPSConnection(u.hostname, u.port, context=ctx, timeout=timeout)
conn.connect()
fingerprint = hashlib.sha256(conn.sock.getpeercert(binary_form=True)).hexdigest()
known_node(f"{u.hostname}:{u.port}", fingerprint)
headers["Authorization"] = fleet_authorization(fingerprint)
else:
conn = http.client.HTTPConnection(u.hostname, u.port, timeout=timeout)
try:
conn.request("GET" if data is None else "POST", path, body=data, headers=headers)
r = conn.getresponse()
out = r.read()
if r.status >= 400:
raise RuntimeError(f"HTTP {r.status}: {out[:200].decode(errors='replace')}")
return out
finally:
conn.close()
def request(base, path, body=None, timeout=15):
return json.loads(fetch(base, path, body, timeout))
def admission_open(base):
try:
h = request(base, "/health", timeout=5)
return h.get("activity", {}).get("admission_open", True) and h.get("jobs_pending", 0) == 0
except Exception:
return False
def generate(base, lyrics, caption, seed, seconds, log, body=None):
origin = {"origin_key": uuid.uuid4().hex, "origin_epoch": time.time_ns() // 1_000_000}
body = body or {"model": MODEL, "prompt": caption, "lyrics": lyrics, "seconds": seconds, "seed": seed, "queue_policy": "reject"}
acc = request(base, "/generate", {**origin, **body}, timeout=120)
job = acc["job_id"]
start = time.monotonic()
last = None
while True:
st = request(base, "/job/" + job)
sig = (st["state"], st.get("stage"))
if sig != last:
log(f" job {job[:8]} {sig[0]} {sig[1] or ''} {st.get('progress', '')}")
last = sig
if st["state"] in ("done", "error", "cancelled"):
break
beat = request(base, "/job/" + job + "/keepalive", origin)
if beat.get("ok") is False:
raise RuntimeError("lease lost: " + str(beat.get("reason")))
if time.monotonic() - start > 1800:
request(base, "/job/" + job + "/cancel", origin)
raise RuntimeError("timeout")
time.sleep(1.0)
if st["state"] != "done":
raise RuntimeError(st.get("error") or st["state"])
art = [a for a in st["artifacts"] if a.get("url", "").startswith("/artifact/")][0]
data = fetch(base, art["url"], timeout=60)
if art.get("sha256") and hashlib.sha256(data).hexdigest() != art["sha256"]:
raise RuntimeError("artifact sha256 mismatch")
return data, art, time.monotonic() - start
def to_44k1(wav):
"""16-bit stereo WAV at any rate -> 44.1 kHz (the separator's rate), by
linear interpolation; unchanged when already 44.1 kHz or not 16-bit stereo."""
if wav[:4] != b"RIFF":
return wav
i, rate, ch, bits, pcm = 12, 0, 0, 0, None
while i + 8 <= len(wav):
cid, size = wav[i:i + 4], int.from_bytes(wav[i + 4:i + 8], "little")
if cid == b"fmt ":
ch = int.from_bytes(wav[i + 10:i + 12], "little")
rate = int.from_bytes(wav[i + 12:i + 16], "little")
bits = int.from_bytes(wav[i + 22:i + 24], "little")
elif cid == b"data":
pcm = wav[i + 8:i + 8 + size]
break
i += 8 + size + (size & 1)
if pcm is None or rate == 44100 or ch != 2 or bits != 16:
return wav
src = array.array("h")
src.frombytes(pcm[:len(pcm) // 4 * 4])
n = len(src) // 2
m = int(n * 44100 / rate)
out = array.array("h", [0]) * (2 * m)
step = rate / 44100
for j in range(m):
x = j * step
k = int(x)
f = x - k
k1 = min(k + 1, n - 1)
out[2 * j] = int(src[2 * k] * (1 - f) + src[2 * k1] * f)
out[2 * j + 1] = int(src[2 * k + 1] * (1 - f) + src[2 * k1 + 1] * f)
body = out.tobytes()
return b"RIFF" + (36 + len(body)).to_bytes(4, "little") + b"WAVEfmt " + (16).to_bytes(4, "little") + (1).to_bytes(2, "little") + (2).to_bytes(2, "little") \
+ (44100).to_bytes(4, "little") + (44100 * 4).to_bytes(4, "little") + (4).to_bytes(2, "little") + (16).to_bytes(2, "little") + b"data" + len(body).to_bytes(4, "little") + body
def separate(node, wav, log):
"""The vocal of a 44.1 kHz stereo WAV via a node's four-stem separator:
16-bit stereo WAV bytes."""
body = {"model": "bs-roformer-4stem", "input_b64": base64.b64encode(wav).decode(), "queue_policy": "queue"}
data, art, secs = generate(node, None, None, None, None, log, body=body)
if data[:4] != b"MPST":
raise RuntimeError("not a stems artifact")
rate = int.from_bytes(data[8:12], "little")
frames = int.from_bytes(data[12:20], "little")
planar = array.array("f")
planar.frombytes(data[20:20 + 8 * frames * 4])
left, right = planar[6 * frames:7 * frames], planar[7 * frames:8 * frames]
pcm = array.array("h", [0]) * (2 * frames)
for i in range(frames):
pcm[2 * i] = max(-32768, min(32767, int(left[i] * 32767)))
pcm[2 * i + 1] = max(-32768, min(32767, int(right[i] * 32767)))
body = pcm.tobytes()
hdr = b"RIFF" + (36 + len(body)).to_bytes(4, "little") + b"WAVEfmt " + (16).to_bytes(4, "little") + (1).to_bytes(2, "little") + (2).to_bytes(2, "little") \
+ rate.to_bytes(4, "little") + (rate * 4).to_bytes(4, "little") + (4).to_bytes(2, "little") + (16).to_bytes(2, "little") + b"data" + len(body).to_bytes(4, "little")
return hdr + body, secs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--node", required=True)
ap.add_argument("--inbox", required=True)
ap.add_argument("--count", type=int, default=0, help="lyric sets to make; 0 = forever")
ap.add_argument("--seconds", type=float, default=35.0)
ap.add_argument("--seed", type=int, default=int(time.time()))
ap.add_argument("--model", default=MODEL)
ap.add_argument("--takes", type=int, default=2, help="songs (seeds) per lyric set; the harvester keeps each line's best take")
ap.add_argument("--batch", type=int, default=4, help="lyric sets generated before the batch is separated")
ap.add_argument("--sep-node", default=None, help="the node that separates the vocals (default: --node)")
a = ap.parse_args()
sep_node = a.sep_node or a.node
inbox = pathlib.Path(a.inbox)
inbox.mkdir(parents=True, exist_ok=True)
rng = random.Random(a.seed)
log = lambda m: print(time.strftime("%H:%M:%S"), m, flush=True)
made = 0
while a.count == 0 or made < a.count:
# A batch: every take of every lyric set on the music model (resident
# across the batch), then every vocal on the separator, so each model
# loads once per batch rather than once per song.
batch = []
for _ in range(a.batch if a.count == 0 else min(a.batch, a.count - made)):
lyrics, caption, voice, style = song(rng)
group = f"{time.strftime('%Y%m%d-%H%M%S')}-{rng.randrange(1 << 31):08x}"
takes = []
while len(takes) < a.takes:
if not admission_open(a.node):
log("node busy or not admitting; waiting")
time.sleep(60)
continue
seed = rng.randrange(1 << 31)
try:
body = {"model": a.model, "prompt": caption, "lyrics": lyrics, "seconds": a.seconds, "seed": seed, "queue_policy": "reject"}
data, art, secs = generate(a.node, lyrics, caption, seed, a.seconds, log, body=body)
data = to_44k1(data)
except Exception as e:
log(f"generation failed: {e}; retrying in 60 s")
time.sleep(60)
continue
takes.append((seed, data, art, secs))
log(f"{group} take {len(takes)}/{a.takes} ({len(data) / 1e6:.1f} MB, {secs:.0f} s)")
batch.append((group, lyrics, caption, voice, style, takes))
for group, lyrics, caption, voice, style, takes in batch:
for k, (seed, data, art, secs) in enumerate(takes):
sid = f"{group}-t{k}"
ext = ".wav" if data[:4] == b"RIFF" else ".bin"
if ext == ".wav":
# The vocal first: the harvester picks a song up when its .wav appears.
try:
vocals, ssecs = separate(sep_node, data, log)
(inbox / (sid + ".vocals.part")).write_bytes(vocals)
(inbox / (sid + ".vocals.part")).rename(inbox / (sid + ".vocals.wav.v"))
log(f" {sid} separated on {sep_node} in {ssecs:.0f} s")
except Exception as e:
log(f" {sid} separation failed ({e}); the harvester will separate")
(inbox / (sid + ".json")).write_text(json.dumps({"lyrics": lyrics, "caption": caption, "voice": voice, "seed": seed,
"model": a.model, "node": a.node, "seconds": a.seconds, "style": style,
"group": group, "take": k, "takes": len(takes),
"content_type": art.get("content_type"), "gen_seconds": secs}, indent=1))
(inbox / (sid + ".lyrics.txt")).write_text(lyrics + "\n")
(inbox / (sid + ".caption.txt")).write_text(caption + "\n")
(inbox / (sid + ext + ".part")).write_bytes(data)
(inbox / (sid + ext + ".part")).rename(inbox / (sid + ext))
made += 1
log(f"set {made}: {group} ({len(takes)} takes)")
if __name__ == "__main__":
main()