docs: karaoke pipeline architecture with dot diagrams

Add docs/karaoke-pipeline.md covering the full streaming pipeline from
MPS through unsandbox API to zerotrust container and back. Includes two
Graphviz dot diagrams (rendered to SVG):

- karaoke-pipeline.dot: full system flow across MPS, API, pool, container
- karaoke-ondemand.dot: watch mode on-demand user flow

Update architecture.md feature toggle matrix and related docs table.
Update CLAUDE.md karaoke section with streaming path and on-demand info.
This commit is contained in:
russell@unturf.com 2026-03-11 17:49:57 -04:00
parent 6fd6bfd756
commit 8649e6aaae
7 changed files with 887 additions and 5 deletions

View file

@ -29,15 +29,20 @@ Files are NEVER streamed through uwsgi. The server only generates presigned URLs
### Karaoke Pipeline (lib/karaoke.py)
Disk-backed vocal isolation pipeline. Downloads media from S3, builds a JSON
request on disk with streaming base64 encoding, POSTs to unsandbox API, streams
response to disk, decodes artifacts one at a time and uploads to S3.
Disk-backed vocal isolation pipeline using spectral mid-side Wiener masking
(`voxsplit.c`, zero ML deps). Streams media to unsandbox via `POST /upload`
(64KB chunks, constant memory), executes in zerotrust container, streams
response back, uploads instrumentals + vocals to S3.
Full architecture doc: `docs/karaoke-pipeline.md` (with dot diagrams).
- **Concurrency**: `ThreadPoolExecutor` sized to account's unsandbox concurrency limit
- **Memory**: ~64KB per worker on the request side (disk-backed, not in-memory)
- **Memory**: ~64KB per worker at every stage (disk-backed, not in-memory)
- **Upstream limit**: 3.698GB / 3,698,742,051 bytes per file (unsandbox `@max_upload_bytes`)
- **Retries**: 3 attempts with exponential backoff (5s, 10s)
- **Callers**: `views/product.py`, `views/shop.py` (backfill), `scripts/backfill_karaoke.py`
- **Callers**: `views/product.py` (upload), `views/watch.py` (on-demand), `views/shop.py` (backfill), `scripts/backfill_karaoke.py`
- **On-demand**: `POST /karaoke/{product_id}` — forks detached child, watch.js 10s refresh detects completion, auto-switches to instrumentals
- **Streaming path**: MPS → `POST /upload` → API encrypts to disk → pool pulls via `GET /internal/upload/{id}` → pipes into container `/root/input/` — zero bytes cross Erlang distribution
## Project Setup

View file

@ -206,6 +206,7 @@ mps_page_session (raw rows)
| Trial | `shop.trial_started_timestamp` | Auto on creation | 21 days |
| Discovery ring | `shop.discovery_ring` | Automatic | Auto-computed |
| Subscriptions | `shop.subscription_*` | `ribbon-settings` | Off |
| Karaoke (vocal isolation) | `shop.unsandbox_*_key` | `unsandbox-settings` | Off |
## Ticket Index
@ -235,5 +236,6 @@ mps_page_session (raw rows)
|-----|---------|
| [Design System](design-system.md) | Design tokens, CSS architecture, component library |
| [JavaScript](JAVASCRIPT.md) | Client-side JS architecture |
| [Karaoke Pipeline](karaoke-pipeline.md) | Vocal isolation: pipeline, on-demand, streaming architecture |
| [Sandbox Mode](sandbox-mode.md) | Creative filter system |
| [Testing Performance](testing-performance.md) | Test suite optimization |

30
docs/karaoke-ondemand.dot Normal file
View file

@ -0,0 +1,30 @@
// On-Demand Karaoke — Watch Mode User Flow
// Render: dot -Tsvg docs/karaoke-ondemand.dot -o docs/karaoke-ondemand.dot.svg
digraph karaoke_ondemand {
rankdir=TB;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
click [label="User clicks 🎤\n(watch.js)", fillcolor="#e0f2f1"];
check [label="Has tracks?", shape=diamond, fillcolor="#fff8e1"];
cycle [label="cycleKaraoke()\nOriginal → Instrumentals → Vocals", fillcolor="#e8f5e9"];
post [label="POST /karaoke/{id}\n(fetch, non-blocking)", fillcolor="#e8eaf6"];
fork [label="Server forks\ndetached grandchild", fillcolor="#e8eaf6"];
process [label="process_karaoke()\n(~30-120s)", fillcolor="#fce4ec"];
hourglass [label="Button shows ⌛\nkaraokeProcessing=true", fillcolor="#e0f2f1"];
refresh [label="10s URL refresh\nfetchWatchData()", fillcolor="#e0f2f1"];
detect [label="instrumentals_url\nappears in JSON?", shape=diamond, fillcolor="#fff8e1"];
autoswitch [label="Auto-switch to\ninstrumentals 🎤", fillcolor="#e8f5e9"];
click -> check;
check -> cycle [label="yes"];
check -> post [label="no (eligible)"];
post -> fork [label="200 {status: processing}"];
post -> hourglass;
fork -> process;
hourglass -> refresh [label="every 10s"];
refresh -> detect;
detect -> refresh [label="not yet"];
detect -> autoswitch [label="tracks ready"];
process -> detect [label="DB updated", style=dashed];
}

View file

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: karaoke_ondemand Pages: 1 -->
<svg width="458pt" height="577pt"
viewBox="0.00 0.00 458.00 577.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 573)">
<title>karaoke_ondemand</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-573 454,-573 454,4 -4,4"/>
<!-- click -->
<g id="node1" class="node">
<title>click</title>
<path fill="#e0f2f1" stroke="black" d="M165,-569C165,-569 88,-569 88,-569 82,-569 76,-563 76,-557 76,-557 76,-545 76,-545 76,-539 82,-533 88,-533 88,-533 165,-533 165,-533 171,-533 177,-539 177,-545 177,-545 177,-557 177,-557 177,-563 171,-569 165,-569"/>
<text text-anchor="middle" x="126.5" y="-554" font-family="monospace" font-size="10.00">User clicks 🎤</text>
<text text-anchor="middle" x="126.5" y="-543" font-family="monospace" font-size="10.00">(watch.js)</text>
</g>
<!-- check -->
<g id="node2" class="node">
<title>check</title>
<path fill="#fff8e1" stroke="black" d="M114.89,-492.98C114.89,-492.98 68.92,-481.02 68.92,-481.02 63.11,-479.51 63.11,-476.49 68.92,-474.98 68.92,-474.98 114.89,-463.02 114.89,-463.02 120.69,-461.51 132.31,-461.51 138.11,-463.02 138.11,-463.02 184.08,-474.98 184.08,-474.98 189.89,-476.49 189.89,-479.51 184.08,-481.02 184.08,-481.02 138.11,-492.98 138.11,-492.98 132.31,-494.49 120.69,-494.49 114.89,-492.98"/>
<text text-anchor="middle" x="126.5" y="-475.5" font-family="monospace" font-size="10.00">Has tracks?</text>
</g>
<!-- click&#45;&gt;check -->
<g id="edge1" class="edge">
<title>click&#45;&gt;check</title>
<path fill="none" stroke="black" d="M126.5,-532.81C126.5,-524.79 126.5,-515.05 126.5,-506.07"/>
<polygon fill="black" stroke="black" points="130,-506.03 126.5,-496.03 123,-506.03 130,-506.03"/>
</g>
<!-- cycle -->
<g id="node3" class="node">
<title>cycle</title>
<path fill="#e8f5e9" stroke="black" d="M203,-414C203,-414 12,-414 12,-414 6,-414 0,-408 0,-402 0,-402 0,-390 0,-390 0,-384 6,-378 12,-378 12,-378 203,-378 203,-378 209,-378 215,-384 215,-390 215,-390 215,-402 215,-402 215,-408 209,-414 203,-414"/>
<text text-anchor="middle" x="107.5" y="-399" font-family="monospace" font-size="10.00">cycleKaraoke()</text>
<text text-anchor="middle" x="107.5" y="-388" font-family="monospace" font-size="10.00">Original → Instrumentals → Vocals</text>
</g>
<!-- check&#45;&gt;cycle -->
<g id="edge2" class="edge">
<title>check&#45;&gt;cycle</title>
<path fill="none" stroke="black" d="M122.66,-460.81C120.17,-450.36 116.88,-436.5 114,-424.36"/>
<polygon fill="black" stroke="black" points="117.32,-423.19 111.6,-414.27 110.51,-424.81 117.32,-423.19"/>
<text text-anchor="middle" x="126.5" y="-434.8" font-family="monospace" font-size="9.00">yes</text>
</g>
<!-- post -->
<g id="node4" class="node">
<title>post</title>
<path fill="#e8eaf6" stroke="black" d="M364,-414C364,-414 245,-414 245,-414 239,-414 233,-408 233,-402 233,-402 233,-390 233,-390 233,-384 239,-378 245,-378 245,-378 364,-378 364,-378 370,-378 376,-384 376,-390 376,-390 376,-402 376,-402 376,-408 370,-414 364,-414"/>
<text text-anchor="middle" x="304.5" y="-399" font-family="monospace" font-size="10.00">POST /karaoke/{id}</text>
<text text-anchor="middle" x="304.5" y="-388" font-family="monospace" font-size="10.00">(fetch, non&#45;blocking)</text>
</g>
<!-- check&#45;&gt;post -->
<g id="edge3" class="edge">
<title>check&#45;&gt;post</title>
<path fill="none" stroke="black" d="M150.51,-466.21C177.74,-453.97 222.83,-433.7 257.2,-418.26"/>
<polygon fill="black" stroke="black" points="258.9,-421.33 266.59,-414.04 256.03,-414.94 258.9,-421.33"/>
<text text-anchor="middle" x="261" y="-434.8" font-family="monospace" font-size="9.00">no (eligible)</text>
</g>
<!-- fork -->
<g id="node5" class="node">
<title>fork</title>
<path fill="#e8eaf6" stroke="black" d="M287,-332C287,-332 180,-332 180,-332 174,-332 168,-326 168,-320 168,-320 168,-308 168,-308 168,-302 174,-296 180,-296 180,-296 287,-296 287,-296 293,-296 299,-302 299,-308 299,-308 299,-320 299,-320 299,-326 293,-332 287,-332"/>
<text text-anchor="middle" x="233.5" y="-317" font-family="monospace" font-size="10.00">Server forks</text>
<text text-anchor="middle" x="233.5" y="-306" font-family="monospace" font-size="10.00">detached grandchild</text>
</g>
<!-- post&#45;&gt;fork -->
<g id="edge4" class="edge">
<title>post&#45;&gt;fork</title>
<path fill="none" stroke="black" d="M253.29,-377.86C245.86,-373.21 239.15,-367.35 234.5,-360 231.18,-354.76 229.82,-348.43 229.53,-342.21"/>
<polygon fill="black" stroke="black" points="233.03,-342.14 229.89,-332.03 226.04,-341.9 233.03,-342.14"/>
<text text-anchor="middle" x="298" y="-352.8" font-family="monospace" font-size="9.00">200 {status: processing}</text>
</g>
<!-- hourglass -->
<g id="node7" class="node">
<title>hourglass</title>
<path fill="#e0f2f1" stroke="black" d="M438,-278C438,-278 313,-278 313,-278 307,-278 301,-272 301,-266 301,-266 301,-254 301,-254 301,-248 307,-242 313,-242 313,-242 438,-242 438,-242 444,-242 450,-248 450,-254 450,-254 450,-266 450,-266 450,-272 444,-278 438,-278"/>
<text text-anchor="middle" x="375.5" y="-263" font-family="monospace" font-size="10.00">Button shows ⌛</text>
<text text-anchor="middle" x="375.5" y="-252" font-family="monospace" font-size="10.00">karaokeProcessing=true</text>
</g>
<!-- post&#45;&gt;hourglass -->
<g id="edge5" class="edge">
<title>post&#45;&gt;hourglass</title>
<path fill="none" stroke="black" d="M342.82,-377.98C350.04,-373.14 356.79,-367.19 361.5,-360 375.36,-338.86 377.98,-309.74 377.67,-288.55"/>
<polygon fill="black" stroke="black" points="381.15,-288.15 377.28,-278.28 374.16,-288.41 381.15,-288.15"/>
</g>
<!-- process -->
<g id="node6" class="node">
<title>process</title>
<path fill="#fce4ec" stroke="black" d="M279,-224C279,-224 184,-224 184,-224 178,-224 172,-218 172,-212 172,-212 172,-200 172,-200 172,-194 178,-188 184,-188 184,-188 279,-188 279,-188 285,-188 291,-194 291,-200 291,-200 291,-212 291,-212 291,-218 285,-224 279,-224"/>
<text text-anchor="middle" x="231.5" y="-209" font-family="monospace" font-size="10.00">process_karaoke()</text>
<text text-anchor="middle" x="231.5" y="-198" font-family="monospace" font-size="10.00">(~30&#45;120s)</text>
</g>
<!-- fork&#45;&gt;process -->
<g id="edge6" class="edge">
<title>fork&#45;&gt;process</title>
<path fill="none" stroke="black" d="M233.18,-295.97C232.87,-279.38 232.38,-253.88 232.02,-234.43"/>
<polygon fill="black" stroke="black" points="235.52,-234.27 231.83,-224.34 228.52,-234.4 235.52,-234.27"/>
</g>
<!-- detect -->
<g id="node9" class="node">
<title>detect</title>
<path fill="#fff8e1" stroke="black" d="M216.86,-139.07C216.86,-139.07 121.14,-114.93 121.14,-114.93 115.32,-113.47 115.32,-110.53 121.14,-109.07 121.14,-109.07 216.86,-84.93 216.86,-84.93 222.68,-83.47 234.32,-83.47 240.14,-84.93 240.14,-84.93 335.86,-109.07 335.86,-109.07 341.68,-110.53 341.68,-113.47 335.86,-114.93 335.86,-114.93 240.14,-139.07 240.14,-139.07 234.32,-140.53 222.68,-140.53 216.86,-139.07"/>
<text text-anchor="middle" x="228.5" y="-115" font-family="monospace" font-size="10.00">instrumentals_url</text>
<text text-anchor="middle" x="228.5" y="-104" font-family="monospace" font-size="10.00">appears in JSON?</text>
</g>
<!-- process&#45;&gt;detect -->
<g id="edge11" class="edge">
<title>process&#45;&gt;detect</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M230.94,-187.7C230.61,-177.61 230.18,-164.46 229.77,-152.01"/>
<polygon fill="black" stroke="black" points="233.26,-151.68 229.44,-141.8 226.27,-151.91 233.26,-151.68"/>
<text text-anchor="middle" x="257" y="-162.8" font-family="monospace" font-size="9.00">DB updated</text>
</g>
<!-- refresh -->
<g id="node8" class="node">
<title>refresh</title>
<path fill="#e0f2f1" stroke="black" d="M345,-36C345,-36 256,-36 256,-36 250,-36 244,-30 244,-24 244,-24 244,-12 244,-12 244,-6 250,0 256,0 256,0 345,0 345,0 351,0 357,-6 357,-12 357,-12 357,-24 357,-24 357,-30 351,-36 345,-36"/>
<text text-anchor="middle" x="300.5" y="-21" font-family="monospace" font-size="10.00">10s URL refresh</text>
<text text-anchor="middle" x="300.5" y="-10" font-family="monospace" font-size="10.00">fetchWatchData()</text>
</g>
<!-- hourglass&#45;&gt;refresh -->
<g id="edge7" class="edge">
<title>hourglass&#45;&gt;refresh</title>
<path fill="none" stroke="black" d="M377.02,-241.77C379.19,-209.07 380.58,-136.4 356.5,-82 350.05,-67.43 338.96,-53.86 328.34,-43.07"/>
<polygon fill="black" stroke="black" points="330.74,-40.52 321.12,-36.07 325.86,-45.55 330.74,-40.52"/>
<text text-anchor="middle" x="400.5" y="-162.8" font-family="monospace" font-size="9.00">every 10s</text>
</g>
<!-- refresh&#45;&gt;detect -->
<g id="edge8" class="edge">
<title>refresh&#45;&gt;detect</title>
<path fill="none" stroke="black" d="M287.08,-36.15C277.84,-47.96 265.27,-64.02 254.14,-78.23"/>
<polygon fill="black" stroke="black" points="251.2,-76.31 247.79,-86.35 256.71,-80.63 251.2,-76.31"/>
</g>
<!-- detect&#45;&gt;refresh -->
<g id="edge9" class="edge">
<title>detect&#45;&gt;refresh</title>
<path fill="none" stroke="black" d="M252.48,-88.02C259.61,-80.65 267.19,-72.26 273.5,-64 277.96,-58.16 282.29,-51.54 286.12,-45.2"/>
<polygon fill="black" stroke="black" points="289.33,-46.65 291.35,-36.25 283.29,-43.12 289.33,-46.65"/>
<text text-anchor="middle" x="298" y="-56.8" font-family="monospace" font-size="9.00">not yet</text>
</g>
<!-- autoswitch -->
<g id="node10" class="node">
<title>autoswitch</title>
<path fill="#e8f5e9" stroke="black" d="M214,-36C214,-36 125,-36 125,-36 119,-36 113,-30 113,-24 113,-24 113,-12 113,-12 113,-6 119,0 125,0 125,0 214,0 214,0 220,0 226,-6 226,-12 226,-12 226,-24 226,-24 226,-30 220,-36 214,-36"/>
<text text-anchor="middle" x="169.5" y="-21" font-family="monospace" font-size="10.00">Auto&#45;switch to</text>
<text text-anchor="middle" x="169.5" y="-10" font-family="monospace" font-size="10.00">instrumentals 🎤</text>
</g>
<!-- detect&#45;&gt;autoswitch -->
<g id="edge10" class="edge">
<title>detect&#45;&gt;autoswitch</title>
<path fill="none" stroke="black" d="M199.97,-89.01C192.3,-81.8 184.7,-73.25 179.5,-64 176.43,-58.55 174.31,-52.23 172.83,-46.09"/>
<polygon fill="black" stroke="black" points="176.24,-45.25 170.92,-36.09 169.36,-46.57 176.24,-45.25"/>
<text text-anchor="middle" x="211.5" y="-56.8" font-family="monospace" font-size="9.00">tracks ready</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

77
docs/karaoke-pipeline.dot Normal file
View file

@ -0,0 +1,77 @@
// Karaoke Pipeline — MPS → Unsandbox → Container → S3
// Render: dot -Tsvg docs/karaoke-pipeline.dot -o docs/karaoke-pipeline.dot.svg
digraph karaoke_pipeline {
rankdir=LR;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
subgraph cluster_mps {
label="MPS (uWSGI)";
style=dashed;
color="#5871ad";
trigger [label="Trigger\n(upload / on-demand / backfill)", fillcolor="#e8eaf6"];
s3_down [label="S3 Download\n→ /tmp/karaoke_*/media.bin\n64KB chunks", fillcolor="#e8eaf6"];
upload_media [label="POST /upload\n(streaming file body)\n→ upload_id", fillcolor="#e8eaf6"];
upload_vox [label="POST /upload\nvoxsplit.c\n→ upload_id", fillcolor="#e8eaf6"];
execute [label="POST /execute\n{upload_ids, script}\ntiny JSON, no file bytes", fillcolor="#e8eaf6"];
stream_resp [label="Stream response\n→ /tmp/karaoke_*/response.json\n64KB chunks", fillcolor="#e8eaf6"];
decode [label="Decode artifacts\n(base64 → tmpfile)\nupload to S3", fillcolor="#e8eaf6"];
db_update [label="Update product\nextensions + file_bytes\nset S3 ACLs", fillcolor="#e8eaf6"];
}
subgraph cluster_api {
label="api.unsandbox.com";
style=dashed;
color="#ad5871";
recv_upload [label="Receive upload\nAES-256-CTR encrypt\n→ /tmp/uploads/{id}.enc\nkey in ETS only", fillcolor="#fce4ec"];
dispatch [label="Dispatch execute\nErlang RPC\n~200B metadata only", fillcolor="#fce4ec"];
}
subgraph cluster_pool {
label="Pool Node";
style=dashed;
color="#58ad71";
pull [label="GET /internal/upload/{id}\nX-Upload-Key auth\nstreaming decrypt", fillcolor="#e8f5e9"];
inject [label="lxc exec ... cat >\n/root/input/{filename}\n64KB streaming pipe", fillcolor="#e8f5e9"];
}
subgraph cluster_container {
label="Zerotrust Container";
style=dashed;
color="#ad8f58";
compile [label="gcc -O2 voxsplit.c -lm", fillcolor="#fff8e1"];
extract [label="ffmpeg -i media\n-vn -acodec pcm_s16le\n-ar 44100 -ac 2\naudio.wav", fillcolor="#fff8e1"];
split [label="voxsplit audio.wav\n→ split-instrumental.wav\n→ split-vocal.wav", fillcolor="#fff8e1"];
remux [label="ffmpeg remux\n(video: copy video +\nisolated audio)\n(audio: copy wav)", fillcolor="#fff8e1"];
artifacts [label="/tmp/artifacts/\ninstrumentals.{ext}\nvocals.{ext}", fillcolor="#fff8e1"];
}
s3 [label="S3 / CDN\n(DO Spaces or BYOB)", shape=cylinder, fillcolor="#f3e5f5"];
browser [label="Browser\n(watch.js)", shape=ellipse, fillcolor="#e0f2f1"];
trigger -> s3_down;
s3_down -> upload_media [label="file on disk"];
s3_down -> upload_vox;
upload_media -> recv_upload [label="streaming\noctet-stream"];
upload_vox -> recv_upload;
recv_upload -> execute [label="upload_id", style=dashed, dir=back];
execute -> dispatch [label="JSON\n{upload_ids}"];
dispatch -> pull [label="RPC\nmetadata only"];
pull -> recv_upload [label="HTTPS GET\nstreaming decrypt", style=dotted];
pull -> inject;
inject -> compile;
compile -> extract;
extract -> split;
split -> remux;
remux -> artifacts;
artifacts -> stream_resp [label="response JSON\nbase64 artifacts", style=dotted];
stream_resp -> decode;
decode -> s3 [label="PUT instrumentals\nPUT vocals"];
db_update -> s3 [label="ACL update", style=dashed];
decode -> db_update;
s3 -> browser [label="presigned URL\n15 min TTL"];
}

View file

@ -0,0 +1,327 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: karaoke_pipeline Pages: 1 -->
<svg width="1637pt" height="501pt"
viewBox="0.00 0.00 1637.00 501.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 497)">
<title>karaoke_pipeline</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-497 1633,-497 1633,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_mps</title>
<polygon fill="none" stroke="#5871ad" stroke-dasharray="5,2" points="8,-286 8,-485 781,-485 781,-286 8,-286"/>
<text text-anchor="middle" x="394.5" y="-469.8" font-family="Times,serif" font-size="14.00">MPS (uWSGI)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_api</title>
<polygon fill="none" stroke="#ad5871" stroke-dasharray="5,2" points="346,-127 346,-278 517,-278 517,-127 346,-127"/>
<text text-anchor="middle" x="431.5" y="-262.8" font-family="Times,serif" font-size="14.00">api.unsandbox.com</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_pool</title>
<polygon fill="none" stroke="#58ad71" stroke-dasharray="5,2" points="26,-8 26,-89 514,-89 514,-8 26,-8"/>
<text text-anchor="middle" x="270" y="-73.8" font-family="Times,serif" font-size="14.00">Pool Node</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_container</title>
<polygon fill="none" stroke="#ad8f58" stroke-dasharray="5,2" points="613,-10 613,-101 1621,-101 1621,-10 613,-10"/>
<text text-anchor="middle" x="1117" y="-85.8" font-family="Times,serif" font-size="14.00">Zerotrust Container</text>
</g>
<!-- trigger -->
<g id="node1" class="node">
<title>trigger</title>
<path fill="#e8eaf6" stroke="black" d="M207,-449C207,-449 28,-449 28,-449 22,-449 16,-443 16,-437 16,-437 16,-425 16,-425 16,-419 22,-413 28,-413 28,-413 207,-413 207,-413 213,-413 219,-419 219,-425 219,-425 219,-437 219,-437 219,-443 213,-449 207,-449"/>
<text text-anchor="middle" x="117.5" y="-434" font-family="monospace" font-size="10.00">Trigger</text>
<text text-anchor="middle" x="117.5" y="-423" font-family="monospace" font-size="10.00">(upload / on&#45;demand / backfill)</text>
</g>
<!-- s3_down -->
<g id="node2" class="node">
<title>s3_down</title>
<path fill="#e8eaf6" stroke="black" d="M506,-394.5C506,-394.5 357,-394.5 357,-394.5 351,-394.5 345,-388.5 345,-382.5 345,-382.5 345,-365.5 345,-365.5 345,-359.5 351,-353.5 357,-353.5 357,-353.5 506,-353.5 506,-353.5 512,-353.5 518,-359.5 518,-365.5 518,-365.5 518,-382.5 518,-382.5 518,-388.5 512,-394.5 506,-394.5"/>
<text text-anchor="middle" x="431.5" y="-382.5" font-family="monospace" font-size="10.00">S3 Download</text>
<text text-anchor="middle" x="431.5" y="-371.5" font-family="monospace" font-size="10.00">→ /tmp/karaoke_*/media.bin</text>
<text text-anchor="middle" x="431.5" y="-360.5" font-family="monospace" font-size="10.00">64KB chunks</text>
</g>
<!-- trigger&#45;&gt;s3_down -->
<g id="edge1" class="edge">
<title>trigger&#45;&gt;s3_down</title>
<path fill="none" stroke="black" d="M217.22,-412.96C254.83,-406.09 297.6,-398.28 334.91,-391.46"/>
<polygon fill="black" stroke="black" points="335.57,-394.9 344.78,-389.66 334.31,-388.01 335.57,-394.9"/>
</g>
<!-- upload_media -->
<g id="node3" class="node">
<title>upload_media</title>
<path fill="#e8eaf6" stroke="black" d="M755,-394.5C755,-394.5 636,-394.5 636,-394.5 630,-394.5 624,-388.5 624,-382.5 624,-382.5 624,-365.5 624,-365.5 624,-359.5 630,-353.5 636,-353.5 636,-353.5 755,-353.5 755,-353.5 761,-353.5 767,-359.5 767,-365.5 767,-365.5 767,-382.5 767,-382.5 767,-388.5 761,-394.5 755,-394.5"/>
<text text-anchor="middle" x="695.5" y="-382.5" font-family="monospace" font-size="10.00">POST /upload</text>
<text text-anchor="middle" x="695.5" y="-371.5" font-family="monospace" font-size="10.00">(streaming file body)</text>
<text text-anchor="middle" x="695.5" y="-360.5" font-family="monospace" font-size="10.00">→ upload_id</text>
</g>
<!-- s3_down&#45;&gt;upload_media -->
<g id="edge2" class="edge">
<title>s3_down&#45;&gt;upload_media</title>
<path fill="none" stroke="black" d="M518.29,-374C548.99,-374 583.48,-374 613.74,-374"/>
<polygon fill="black" stroke="black" points="613.83,-377.5 623.83,-374 613.83,-370.5 613.83,-377.5"/>
<text text-anchor="middle" x="568" y="-376.8" font-family="monospace" font-size="9.00">file on disk</text>
</g>
<!-- upload_vox -->
<g id="node4" class="node">
<title>upload_vox</title>
<path fill="#e8eaf6" stroke="black" d="M728,-335.5C728,-335.5 663,-335.5 663,-335.5 657,-335.5 651,-329.5 651,-323.5 651,-323.5 651,-306.5 651,-306.5 651,-300.5 657,-294.5 663,-294.5 663,-294.5 728,-294.5 728,-294.5 734,-294.5 740,-300.5 740,-306.5 740,-306.5 740,-323.5 740,-323.5 740,-329.5 734,-335.5 728,-335.5"/>
<text text-anchor="middle" x="695.5" y="-323.5" font-family="monospace" font-size="10.00">POST /upload</text>
<text text-anchor="middle" x="695.5" y="-312.5" font-family="monospace" font-size="10.00">voxsplit.c</text>
<text text-anchor="middle" x="695.5" y="-301.5" font-family="monospace" font-size="10.00">→ upload_id</text>
</g>
<!-- s3_down&#45;&gt;upload_vox -->
<g id="edge3" class="edge">
<title>s3_down&#45;&gt;upload_vox</title>
<path fill="none" stroke="black" d="M518.29,-354.68C558.63,-345.6 605.49,-335.04 640.74,-327.11"/>
<polygon fill="black" stroke="black" points="641.84,-330.45 650.83,-324.83 640.3,-323.62 641.84,-330.45"/>
</g>
<!-- recv_upload -->
<g id="node9" class="node">
<title>recv_upload</title>
<path fill="#fce4ec" stroke="black" d="M497,-247C497,-247 366,-247 366,-247 360,-247 354,-241 354,-235 354,-235 354,-207 354,-207 354,-201 360,-195 366,-195 366,-195 497,-195 497,-195 503,-195 509,-201 509,-207 509,-207 509,-235 509,-235 509,-241 503,-247 497,-247"/>
<text text-anchor="middle" x="431.5" y="-235" font-family="monospace" font-size="10.00">Receive upload</text>
<text text-anchor="middle" x="431.5" y="-224" font-family="monospace" font-size="10.00">AES&#45;256&#45;CTR encrypt</text>
<text text-anchor="middle" x="431.5" y="-213" font-family="monospace" font-size="10.00">→ /tmp/uploads/{id}.enc</text>
<text text-anchor="middle" x="431.5" y="-202" font-family="monospace" font-size="10.00">key in ETS only</text>
</g>
<!-- upload_media&#45;&gt;recv_upload -->
<g id="edge4" class="edge">
<title>upload_media&#45;&gt;recv_upload</title>
<path fill="none" stroke="black" d="M631.5,-353.46C625.16,-350.83 618.89,-348 613,-345 598.87,-337.79 526.49,-287.31 477.79,-253.02"/>
<polygon fill="black" stroke="black" points="479.78,-250.14 469.59,-247.24 475.75,-255.86 479.78,-250.14"/>
<text text-anchor="middle" x="568" y="-348.8" font-family="monospace" font-size="9.00">streaming</text>
<text text-anchor="middle" x="568" y="-338.8" font-family="monospace" font-size="9.00">octet&#45;stream</text>
</g>
<!-- upload_vox&#45;&gt;recv_upload -->
<g id="edge5" class="edge">
<title>upload_vox&#45;&gt;recv_upload</title>
<path fill="none" stroke="black" d="M665.99,-294.44C647.88,-282.24 623.49,-267.38 600,-258 574.5,-247.81 545.63,-240.16 519.14,-234.54"/>
<polygon fill="black" stroke="black" points="519.61,-231.07 509.11,-232.49 518.2,-237.93 519.61,-231.07"/>
</g>
<!-- execute -->
<g id="node5" class="node">
<title>execute</title>
<path fill="#e8eaf6" stroke="black" d="M186,-394.5C186,-394.5 49,-394.5 49,-394.5 43,-394.5 37,-388.5 37,-382.5 37,-382.5 37,-365.5 37,-365.5 37,-359.5 43,-353.5 49,-353.5 49,-353.5 186,-353.5 186,-353.5 192,-353.5 198,-359.5 198,-365.5 198,-365.5 198,-382.5 198,-382.5 198,-388.5 192,-394.5 186,-394.5"/>
<text text-anchor="middle" x="117.5" y="-382.5" font-family="monospace" font-size="10.00">POST /execute</text>
<text text-anchor="middle" x="117.5" y="-371.5" font-family="monospace" font-size="10.00">{upload_ids, script}</text>
<text text-anchor="middle" x="117.5" y="-360.5" font-family="monospace" font-size="10.00">tiny JSON, no file bytes</text>
</g>
<!-- dispatch -->
<g id="node10" class="node">
<title>dispatch</title>
<path fill="#fce4ec" stroke="black" d="M485,-176.5C485,-176.5 378,-176.5 378,-176.5 372,-176.5 366,-170.5 366,-164.5 366,-164.5 366,-147.5 366,-147.5 366,-141.5 372,-135.5 378,-135.5 378,-135.5 485,-135.5 485,-135.5 491,-135.5 497,-141.5 497,-147.5 497,-147.5 497,-164.5 497,-164.5 497,-170.5 491,-176.5 485,-176.5"/>
<text text-anchor="middle" x="431.5" y="-164.5" font-family="monospace" font-size="10.00">Dispatch execute</text>
<text text-anchor="middle" x="431.5" y="-153.5" font-family="monospace" font-size="10.00">Erlang RPC</text>
<text text-anchor="middle" x="431.5" y="-142.5" font-family="monospace" font-size="10.00">~200B metadata only</text>
</g>
<!-- execute&#45;&gt;dispatch -->
<g id="edge7" class="edge">
<title>execute&#45;&gt;dispatch</title>
<path fill="none" stroke="black" d="M198.38,-360C206.06,-356.12 213.15,-351.21 219,-345 243.99,-318.47 216.7,-295.27 237,-265 264.19,-224.45 288.69,-232.27 327,-202 335.4,-195.36 335.78,-191.43 345,-186 348.66,-183.85 352.5,-181.81 356.44,-179.9"/>
<polygon fill="black" stroke="black" points="358.13,-182.97 365.78,-175.65 355.23,-176.6 358.13,-182.97"/>
<text text-anchor="middle" x="282" y="-277.8" font-family="monospace" font-size="9.00">JSON</text>
<text text-anchor="middle" x="282" y="-267.8" font-family="monospace" font-size="9.00">{upload_ids}</text>
</g>
<!-- stream_resp -->
<g id="node6" class="node">
<title>stream_resp</title>
<path fill="#e8eaf6" stroke="black" d="M204,-335.5C204,-335.5 31,-335.5 31,-335.5 25,-335.5 19,-329.5 19,-323.5 19,-323.5 19,-306.5 19,-306.5 19,-300.5 25,-294.5 31,-294.5 31,-294.5 204,-294.5 204,-294.5 210,-294.5 216,-300.5 216,-306.5 216,-306.5 216,-323.5 216,-323.5 216,-329.5 210,-335.5 204,-335.5"/>
<text text-anchor="middle" x="117.5" y="-323.5" font-family="monospace" font-size="10.00">Stream response</text>
<text text-anchor="middle" x="117.5" y="-312.5" font-family="monospace" font-size="10.00">→ /tmp/karaoke_*/response.json</text>
<text text-anchor="middle" x="117.5" y="-301.5" font-family="monospace" font-size="10.00">64KB chunks</text>
</g>
<!-- decode -->
<g id="node7" class="node">
<title>decode</title>
<path fill="#e8eaf6" stroke="black" d="M482,-453.5C482,-453.5 381,-453.5 381,-453.5 375,-453.5 369,-447.5 369,-441.5 369,-441.5 369,-424.5 369,-424.5 369,-418.5 375,-412.5 381,-412.5 381,-412.5 482,-412.5 482,-412.5 488,-412.5 494,-418.5 494,-424.5 494,-424.5 494,-441.5 494,-441.5 494,-447.5 488,-453.5 482,-453.5"/>
<text text-anchor="middle" x="431.5" y="-441.5" font-family="monospace" font-size="10.00">Decode artifacts</text>
<text text-anchor="middle" x="431.5" y="-430.5" font-family="monospace" font-size="10.00">(base64 → tmpfile)</text>
<text text-anchor="middle" x="431.5" y="-419.5" font-family="monospace" font-size="10.00">upload to S3</text>
</g>
<!-- stream_resp&#45;&gt;decode -->
<g id="edge17" class="edge">
<title>stream_resp&#45;&gt;decode</title>
<path fill="none" stroke="black" d="M191.93,-335.54C201.11,-338.51 210.32,-341.69 219,-345 276.78,-367.01 287.63,-380.92 345,-404 349.49,-405.81 354.14,-407.6 358.85,-409.35"/>
<polygon fill="black" stroke="black" points="357.96,-412.76 368.56,-412.9 360.36,-406.18 357.96,-412.76"/>
</g>
<!-- db_update -->
<g id="node8" class="node">
<title>db_update</title>
<path fill="#e8eaf6" stroke="black" d="M761,-453.5C761,-453.5 630,-453.5 630,-453.5 624,-453.5 618,-447.5 618,-441.5 618,-441.5 618,-424.5 618,-424.5 618,-418.5 624,-412.5 630,-412.5 630,-412.5 761,-412.5 761,-412.5 767,-412.5 773,-418.5 773,-424.5 773,-424.5 773,-441.5 773,-441.5 773,-447.5 767,-453.5 761,-453.5"/>
<text text-anchor="middle" x="695.5" y="-441.5" font-family="monospace" font-size="10.00">Update product</text>
<text text-anchor="middle" x="695.5" y="-430.5" font-family="monospace" font-size="10.00">extensions + file_bytes</text>
<text text-anchor="middle" x="695.5" y="-419.5" font-family="monospace" font-size="10.00">set S3 ACLs</text>
</g>
<!-- decode&#45;&gt;db_update -->
<g id="edge20" class="edge">
<title>decode&#45;&gt;db_update</title>
<path fill="none" stroke="black" d="M494.26,-433C528,-433 570.34,-433 607.39,-433"/>
<polygon fill="black" stroke="black" points="607.69,-436.5 617.69,-433 607.69,-429.5 607.69,-436.5"/>
</g>
<!-- s3 -->
<g id="node18" class="node">
<title>s3</title>
<path fill="#f3e5f5" stroke="black" d="M999,-485.98C999,-488.06 969.64,-489.75 933.5,-489.75 897.36,-489.75 868,-488.06 868,-485.98 868,-485.98 868,-452.02 868,-452.02 868,-449.94 897.36,-448.25 933.5,-448.25 969.64,-448.25 999,-449.94 999,-452.02 999,-452.02 999,-485.98 999,-485.98"/>
<path fill="none" stroke="black" d="M999,-485.98C999,-483.9 969.64,-482.21 933.5,-482.21 897.36,-482.21 868,-483.9 868,-485.98"/>
<text text-anchor="middle" x="933.5" y="-472" font-family="monospace" font-size="10.00">S3 / CDN</text>
<text text-anchor="middle" x="933.5" y="-461" font-family="monospace" font-size="10.00">(DO Spaces or BYOB)</text>
</g>
<!-- decode&#45;&gt;s3 -->
<g id="edge18" class="edge">
<title>decode&#45;&gt;s3</title>
<path fill="none" stroke="black" d="M494.05,-445.28C528.82,-451.71 573.17,-459.04 613,-463 691.77,-470.84 711.86,-467.67 791,-469 812.63,-469.36 836.12,-469.48 857.63,-469.47"/>
<polygon fill="black" stroke="black" points="857.69,-472.97 867.69,-469.46 857.68,-465.97 857.69,-472.97"/>
<text text-anchor="middle" x="695.5" y="-480.8" font-family="monospace" font-size="9.00">PUT instrumentals</text>
<text text-anchor="middle" x="695.5" y="-470.8" font-family="monospace" font-size="9.00">PUT vocals</text>
</g>
<!-- db_update&#45;&gt;s3 -->
<g id="edge19" class="edge">
<title>db_update&#45;&gt;s3</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M773.12,-444.69C800.21,-448.82 830.64,-453.46 857.58,-457.57"/>
<polygon fill="black" stroke="black" points="857.31,-461.07 867.73,-459.12 858.37,-454.15 857.31,-461.07"/>
<text text-anchor="middle" x="817.5" y="-457.8" font-family="monospace" font-size="9.00">ACL update</text>
</g>
<!-- recv_upload&#45;&gt;execute -->
<g id="edge6" class="edge">
<title>recv_upload&#45;&gt;execute</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M384.11,-252.78C342.4,-280.21 278.7,-319.36 219,-345 211.91,-348.05 204.4,-350.88 196.81,-353.49"/>
<polygon fill="black" stroke="black" points="386.26,-255.55 392.66,-247.11 382.39,-249.72 386.26,-255.55"/>
<text text-anchor="middle" x="282" y="-338.8" font-family="monospace" font-size="9.00">upload_id</text>
</g>
<!-- pull -->
<g id="node11" class="node">
<title>pull</title>
<path fill="#e8f5e9" stroke="black" d="M189,-57.5C189,-57.5 46,-57.5 46,-57.5 40,-57.5 34,-51.5 34,-45.5 34,-45.5 34,-28.5 34,-28.5 34,-22.5 40,-16.5 46,-16.5 46,-16.5 189,-16.5 189,-16.5 195,-16.5 201,-22.5 201,-28.5 201,-28.5 201,-45.5 201,-45.5 201,-51.5 195,-57.5 189,-57.5"/>
<text text-anchor="middle" x="117.5" y="-45.5" font-family="monospace" font-size="10.00">GET /internal/upload/{id}</text>
<text text-anchor="middle" x="117.5" y="-34.5" font-family="monospace" font-size="10.00">X&#45;Upload&#45;Key auth</text>
<text text-anchor="middle" x="117.5" y="-23.5" font-family="monospace" font-size="10.00">streaming decrypt</text>
</g>
<!-- dispatch&#45;&gt;pull -->
<g id="edge8" class="edge">
<title>dispatch&#45;&gt;pull</title>
<path fill="none" stroke="black" d="M376.65,-135.46C322.62,-114.85 239.42,-83.12 182.16,-61.28"/>
<polygon fill="black" stroke="black" points="183.08,-57.89 172.49,-57.59 180.59,-64.43 183.08,-57.89"/>
<text text-anchor="middle" x="282" y="-127.8" font-family="monospace" font-size="9.00">RPC</text>
<text text-anchor="middle" x="282" y="-117.8" font-family="monospace" font-size="9.00">metadata only</text>
</g>
<!-- pull&#45;&gt;recv_upload -->
<g id="edge9" class="edge">
<title>pull&#45;&gt;recv_upload</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M138.8,-57.88C161.27,-80.13 199.3,-115.33 237,-139 281.34,-166.83 296.77,-165.64 345,-186 348.91,-187.65 352.92,-189.33 356.97,-191.02"/>
<polygon fill="black" stroke="black" points="355.9,-194.37 366.47,-194.97 358.58,-187.9 355.9,-194.37"/>
<text text-anchor="middle" x="282" y="-190.8" font-family="monospace" font-size="9.00">HTTPS GET</text>
<text text-anchor="middle" x="282" y="-180.8" font-family="monospace" font-size="9.00">streaming decrypt</text>
</g>
<!-- inject -->
<g id="node12" class="node">
<title>inject</title>
<path fill="#e8f5e9" stroke="black" d="M494,-57.5C494,-57.5 369,-57.5 369,-57.5 363,-57.5 357,-51.5 357,-45.5 357,-45.5 357,-28.5 357,-28.5 357,-22.5 363,-16.5 369,-16.5 369,-16.5 494,-16.5 494,-16.5 500,-16.5 506,-22.5 506,-28.5 506,-28.5 506,-45.5 506,-45.5 506,-51.5 500,-57.5 494,-57.5"/>
<text text-anchor="middle" x="431.5" y="-45.5" font-family="monospace" font-size="10.00">lxc exec ... cat &gt;</text>
<text text-anchor="middle" x="431.5" y="-34.5" font-family="monospace" font-size="10.00">/root/input/{filename}</text>
<text text-anchor="middle" x="431.5" y="-23.5" font-family="monospace" font-size="10.00">64KB streaming pipe</text>
</g>
<!-- pull&#45;&gt;inject -->
<g id="edge10" class="edge">
<title>pull&#45;&gt;inject</title>
<path fill="none" stroke="black" d="M201.29,-37C246.27,-37 301.75,-37 346.86,-37"/>
<polygon fill="black" stroke="black" points="346.91,-40.5 356.91,-37 346.91,-33.5 346.91,-40.5"/>
</g>
<!-- compile -->
<g id="node13" class="node">
<title>compile</title>
<path fill="#fff8e1" stroke="black" d="M758,-57C758,-57 633,-57 633,-57 627,-57 621,-51 621,-45 621,-45 621,-33 621,-33 621,-27 627,-21 633,-21 633,-21 758,-21 758,-21 764,-21 770,-27 770,-33 770,-33 770,-45 770,-45 770,-51 764,-57 758,-57"/>
<text text-anchor="middle" x="695.5" y="-36.5" font-family="monospace" font-size="10.00">gcc &#45;O2 voxsplit.c &#45;lm</text>
</g>
<!-- inject&#45;&gt;compile -->
<g id="edge11" class="edge">
<title>inject&#45;&gt;compile</title>
<path fill="none" stroke="black" d="M506.18,-37.56C538.69,-37.81 577.06,-38.1 610.66,-38.36"/>
<polygon fill="black" stroke="black" points="610.89,-41.86 620.92,-38.44 610.95,-34.86 610.89,-41.86"/>
</g>
<!-- extract -->
<g id="node14" class="node">
<title>extract</title>
<path fill="#fff8e1" stroke="black" d="M993,-70C993,-70 874,-70 874,-70 868,-70 862,-64 862,-58 862,-58 862,-30 862,-30 862,-24 868,-18 874,-18 874,-18 993,-18 993,-18 999,-18 1005,-24 1005,-30 1005,-30 1005,-58 1005,-58 1005,-64 999,-70 993,-70"/>
<text text-anchor="middle" x="933.5" y="-58" font-family="monospace" font-size="10.00">ffmpeg &#45;i media</text>
<text text-anchor="middle" x="933.5" y="-47" font-family="monospace" font-size="10.00">&#45;vn &#45;acodec pcm_s16le</text>
<text text-anchor="middle" x="933.5" y="-36" font-family="monospace" font-size="10.00">&#45;ar 44100 &#45;ac 2</text>
<text text-anchor="middle" x="933.5" y="-25" font-family="monospace" font-size="10.00">audio.wav</text>
</g>
<!-- compile&#45;&gt;extract -->
<g id="edge12" class="edge">
<title>compile&#45;&gt;extract</title>
<path fill="none" stroke="black" d="M770.2,-40.56C795.99,-41.11 825.08,-41.72 851.44,-42.28"/>
<polygon fill="black" stroke="black" points="851.71,-45.79 861.78,-42.5 851.85,-38.79 851.71,-45.79"/>
</g>
<!-- split -->
<g id="node15" class="node">
<title>split</title>
<path fill="#fff8e1" stroke="black" d="M1259,-64.5C1259,-64.5 1122,-64.5 1122,-64.5 1116,-64.5 1110,-58.5 1110,-52.5 1110,-52.5 1110,-35.5 1110,-35.5 1110,-29.5 1116,-23.5 1122,-23.5 1122,-23.5 1259,-23.5 1259,-23.5 1265,-23.5 1271,-29.5 1271,-35.5 1271,-35.5 1271,-52.5 1271,-52.5 1271,-58.5 1265,-64.5 1259,-64.5"/>
<text text-anchor="middle" x="1190.5" y="-52.5" font-family="monospace" font-size="10.00">voxsplit audio.wav</text>
<text text-anchor="middle" x="1190.5" y="-41.5" font-family="monospace" font-size="10.00">→ split&#45;instrumental.wav</text>
<text text-anchor="middle" x="1190.5" y="-30.5" font-family="monospace" font-size="10.00">→ split&#45;vocal.wav</text>
</g>
<!-- extract&#45;&gt;split -->
<g id="edge13" class="edge">
<title>extract&#45;&gt;split</title>
<path fill="none" stroke="black" d="M1005.19,-44C1034.33,-44 1068.43,-44 1099.33,-44"/>
<polygon fill="black" stroke="black" points="1099.68,-47.5 1109.68,-44 1099.68,-40.5 1099.68,-47.5"/>
</g>
<!-- remux -->
<g id="node16" class="node">
<title>remux</title>
<path fill="#fff8e1" stroke="black" d="M1433,-70C1433,-70 1320,-70 1320,-70 1314,-70 1308,-64 1308,-58 1308,-58 1308,-30 1308,-30 1308,-24 1314,-18 1320,-18 1320,-18 1433,-18 1433,-18 1439,-18 1445,-24 1445,-30 1445,-30 1445,-58 1445,-58 1445,-64 1439,-70 1433,-70"/>
<text text-anchor="middle" x="1376.5" y="-58" font-family="monospace" font-size="10.00">ffmpeg remux</text>
<text text-anchor="middle" x="1376.5" y="-47" font-family="monospace" font-size="10.00">(video: copy video +</text>
<text text-anchor="middle" x="1376.5" y="-36" font-family="monospace" font-size="10.00">isolated audio)</text>
<text text-anchor="middle" x="1376.5" y="-25" font-family="monospace" font-size="10.00">(audio: copy wav)</text>
</g>
<!-- split&#45;&gt;remux -->
<g id="edge14" class="edge">
<title>split&#45;&gt;remux</title>
<path fill="none" stroke="black" d="M1271.13,-44C1279.93,-44 1288.88,-44 1297.65,-44"/>
<polygon fill="black" stroke="black" points="1297.85,-47.5 1307.85,-44 1297.85,-40.5 1297.85,-47.5"/>
</g>
<!-- artifacts -->
<g id="node17" class="node">
<title>artifacts</title>
<path fill="#fff8e1" stroke="black" d="M1601,-67.5C1601,-67.5 1494,-67.5 1494,-67.5 1488,-67.5 1482,-61.5 1482,-55.5 1482,-55.5 1482,-38.5 1482,-38.5 1482,-32.5 1488,-26.5 1494,-26.5 1494,-26.5 1601,-26.5 1601,-26.5 1607,-26.5 1613,-32.5 1613,-38.5 1613,-38.5 1613,-55.5 1613,-55.5 1613,-61.5 1607,-67.5 1601,-67.5"/>
<text text-anchor="middle" x="1547.5" y="-55.5" font-family="monospace" font-size="10.00">/tmp/artifacts/</text>
<text text-anchor="middle" x="1547.5" y="-44.5" font-family="monospace" font-size="10.00">instrumentals.{ext}</text>
<text text-anchor="middle" x="1547.5" y="-33.5" font-family="monospace" font-size="10.00">vocals.{ext}</text>
</g>
<!-- remux&#45;&gt;artifacts -->
<g id="edge15" class="edge">
<title>remux&#45;&gt;artifacts</title>
<path fill="none" stroke="black" d="M1445.26,-45.2C1453.91,-45.36 1462.8,-45.51 1471.55,-45.67"/>
<polygon fill="black" stroke="black" points="1471.68,-49.17 1481.74,-45.85 1471.8,-42.17 1471.68,-49.17"/>
</g>
<!-- artifacts&#45;&gt;stream_resp -->
<g id="edge16" class="edge">
<title>artifacts&#45;&gt;stream_resp</title>
<path fill="none" stroke="black" stroke-dasharray="1,5" d="M1510.87,-67.65C1477.53,-85 1425.76,-107 1377.5,-107 430.5,-107 430.5,-107 430.5,-107 343.6,-107 310.34,-88.39 237,-135 180.85,-170.69 145.52,-243.66 129.19,-284.89"/>
<polygon fill="black" stroke="black" points="125.85,-283.81 125.54,-294.4 132.39,-286.32 125.85,-283.81"/>
<text text-anchor="middle" x="933.5" y="-119.8" font-family="monospace" font-size="9.00">response JSON</text>
<text text-anchor="middle" x="933.5" y="-109.8" font-family="monospace" font-size="9.00">base64 artifacts</text>
</g>
<!-- browser -->
<g id="node19" class="node">
<title>browser</title>
<ellipse fill="#e0f2f1" stroke="black" cx="1190.5" cy="-469" rx="54.39" ry="21.43"/>
<text text-anchor="middle" x="1190.5" y="-472" font-family="monospace" font-size="10.00">Browser</text>
<text text-anchor="middle" x="1190.5" y="-461" font-family="monospace" font-size="10.00">(watch.js)</text>
</g>
<!-- s3&#45;&gt;browser -->
<g id="edge21" class="edge">
<title>s3&#45;&gt;browser</title>
<path fill="none" stroke="black" d="M999.18,-469C1037.91,-469 1086.97,-469 1125.57,-469"/>
<polygon fill="black" stroke="black" points="1125.66,-472.5 1135.66,-469 1125.66,-465.5 1125.66,-472.5"/>
<text text-anchor="middle" x="1057.5" y="-481.8" font-family="monospace" font-size="9.00">presigned URL</text>
<text text-anchor="middle" x="1057.5" y="-471.8" font-family="monospace" font-size="9.00">15 min TTL</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

286
docs/karaoke-pipeline.md Normal file
View file

@ -0,0 +1,286 @@
# Karaoke Pipeline — Vocal Isolation via Unsandbox
## Overview
MPS separates instrumentals and vocals from audio/video uploads using spectral
mid-side Wiener masking (`voxsplit.c`, zero ML dependencies). Processing runs
inside unsandbox zerotrust containers. The pipeline is disk-backed with ~64KB
memory per worker at every stage.
## Architecture Diagram
```dot
// Render: dot -Tsvg docs/karaoke-pipeline.dot -o docs/karaoke-pipeline.dot.svg
digraph karaoke_pipeline {
rankdir=LR;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
subgraph cluster_mps {
label="MPS (uWSGI)";
style=dashed;
color="#5871ad";
trigger [label="Trigger\n(upload / on-demand / backfill)", fillcolor="#e8eaf6"];
s3_down [label="S3 Download\n→ /tmp/karaoke_*/media.bin\n64KB chunks", fillcolor="#e8eaf6"];
upload_media [label="POST /upload\n(streaming file body)\n→ upload_id", fillcolor="#e8eaf6"];
upload_vox [label="POST /upload\nvoxsplit.c\n→ upload_id", fillcolor="#e8eaf6"];
execute [label="POST /execute\n{upload_ids, script}\ntiny JSON, no file bytes", fillcolor="#e8eaf6"];
stream_resp [label="Stream response\n→ /tmp/karaoke_*/response.json\n64KB chunks", fillcolor="#e8eaf6"];
decode [label="Decode artifacts\n(base64 → tmpfile)\nupload to S3", fillcolor="#e8eaf6"];
db_update [label="Update product\nextensions + file_bytes\nset S3 ACLs", fillcolor="#e8eaf6"];
}
subgraph cluster_api {
label="api.unsandbox.com";
style=dashed;
color="#ad5871";
recv_upload [label="Receive upload\nAES-256-CTR encrypt\n→ /tmp/uploads/{id}.enc\nkey in ETS only", fillcolor="#fce4ec"];
dispatch [label="Dispatch execute\nErlang RPC\n~200B metadata only", fillcolor="#fce4ec"];
}
subgraph cluster_pool {
label="Pool Node";
style=dashed;
color="#58ad71";
pull [label="GET /internal/upload/{id}\nX-Upload-Key auth\nstreaming decrypt", fillcolor="#e8f5e9"];
inject [label="lxc exec ... cat >\n/root/input/{filename}\n64KB streaming pipe", fillcolor="#e8f5e9"];
}
subgraph cluster_container {
label="Zerotrust Container";
style=dashed;
color="#ad8f58";
compile [label="gcc -O2 voxsplit.c -lm", fillcolor="#fff8e1"];
extract [label="ffmpeg -i media\n-vn -acodec pcm_s16le\n-ar 44100 -ac 2\naudio.wav", fillcolor="#fff8e1"];
split [label="voxsplit audio.wav\n→ split-instrumental.wav\n→ split-vocal.wav", fillcolor="#fff8e1"];
remux [label="ffmpeg remux\n(video: copy video +\nisolated audio)\n(audio: copy wav)", fillcolor="#fff8e1"];
artifacts [label="/tmp/artifacts/\ninstrumentals.{ext}\nvocals.{ext}", fillcolor="#fff8e1"];
}
s3 [label="S3 / CDN\n(DO Spaces or BYOB)", shape=cylinder, fillcolor="#f3e5f5"];
browser [label="Browser\n(watch.js)", shape=ellipse, fillcolor="#e0f2f1"];
trigger -> s3_down;
s3_down -> upload_media [label="file on disk"];
s3_down -> upload_vox;
upload_media -> recv_upload [label="streaming\noctet-stream"];
upload_vox -> recv_upload;
recv_upload -> execute [label="upload_id", style=dashed, dir=back];
execute -> dispatch [label="JSON\n{upload_ids}"];
dispatch -> pull [label="RPC\nmetadata only"];
pull -> recv_upload [label="HTTPS GET\nstreaming decrypt", style=dotted];
pull -> inject;
inject -> compile;
compile -> extract;
extract -> split;
split -> remux;
remux -> artifacts;
artifacts -> stream_resp [label="response JSON\nbase64 artifacts", style=dotted];
stream_resp -> decode;
decode -> s3 [label="PUT instrumentals\nPUT vocals"];
db_update -> s3 [label="ACL update", style=dashed];
decode -> db_update;
s3 -> browser [label="presigned URL\n15 min TTL"];
}
```
## Pipeline Stages
### Stage 1: Trigger
Three entry points, all converge on `process_karaoke()`:
| Entry Point | File | When |
|-------------|------|------|
| Upload | `views/product.py:382` | User uploads audio/video to product |
| On-demand | `views/watch.py:karaoke_process` | User clicks 🎤 button in watch mode |
| Backfill | `lib/karaoke.py:backfill_karaoke_async` | Shop owner triggers from settings |
On-demand and backfill fork a detached grandchild process (double-fork +
`os.setsid()`) that survives uWSGI worker recycling. Per-product lockfile
(`/tmp/karaoke_{product_id}.lock`) prevents duplicate processing.
### Stage 2: Download from S3
```python
# karaoke.py:217-226
resp = s3_client.get_object(Bucket=bucket, Key=s3_key)
with open(media_path, "wb") as f:
while True:
chunk = body.read(65536) # 64KB
if not chunk: break
f.write(chunk)
```
### Stage 3: Upload to Unsandbox API
```python
# karaoke.py:45-77
# HMAC signs empty body — API skips body parsing for /upload
headers = {"X-Filename": filename, "Content-Type": "application/octet-stream"}
with open(file_path, "rb") as body:
response = requests.post(url, data=body, headers=headers)
# Returns: {"upload_id": "uuid"}
```
Two uploads: the media file + `voxsplit.c` source. Both stream from disk,
constant memory.
### Stage 4: API Receives + Encrypts
`upload_store.ex` writes incoming bytes to `/tmp/uploads/{id}.enc` with
AES-256-CTR encryption. Unique key+IV per upload. Keys live only in ETS
(BEAM memory, never written to disk). Files auto-expire after 15 minutes.
### Stage 5: Execute (Metadata Only)
```python
# karaoke.py:80-125
payload = {
"language": "bash",
"code": script,
"network_mode": "zerotrust",
"input_files": [
{"upload_id": media_id, "filename": "media"},
{"upload_id": voxsplit_id, "filename": "voxsplit.c"},
],
}
```
Tiny JSON body. Zero file content crosses Erlang distribution.
### Stage 6: Pool Pulls Files
Pool node calls `GET /internal/upload/{id}` back to the API. API decrypts
on-the-fly, streams plaintext. Pool pipes into container:
```
lxc exec {container} -- sh -c 'cat > /root/input/media'
```
`/tmp/input` symlinks to `/root/input/` (canonical location).
### Stage 7: Container Processing
```bash
# Compile voxsplit (spectral mid-side Wiener masking, libc + libm only)
gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm
# Extract audio from media
ffmpeg -y -i /tmp/input/media -vn -acodec pcm_s16le -ar 44100 -ac 2 /tmp/audio.wav
# Separate instrumentals and vocals
/tmp/voxsplit /tmp/audio.wav -o /tmp/split
# For video: remux original video with each isolated audio
ffmpeg -y -i /tmp/input/media -i /tmp/split-instrumental.wav \
-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/instrumentals.mp4
ffmpeg -y -i /tmp/input/media -i /tmp/split-vocal.wav \
-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/vocals.mp4
```
### Stage 8: Response + S3 Upload
Response JSON streams back to MPS temp file. Artifacts are base64-encoded in
the response. MPS decodes each one to a temp file, uploads to S3, deletes
temp file. Updates product metadata and S3 ACLs.
## On-Demand Flow (Watch Mode)
```dot
// Render: dot -Tsvg docs/karaoke-ondemand.dot -o docs/karaoke-ondemand.dot.svg
digraph karaoke_ondemand {
rankdir=TB;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
click [label="User clicks 🎤\n(watch.js)", fillcolor="#e0f2f1"];
check [label="Has tracks?", shape=diamond, fillcolor="#fff8e1"];
cycle [label="cycleKaraoke()\nOriginal → Instrumentals → Vocals", fillcolor="#e8f5e9"];
post [label="POST /karaoke/{id}\n(fetch, non-blocking)", fillcolor="#e8eaf6"];
fork [label="Server forks\ndetached grandchild", fillcolor="#e8eaf6"];
process [label="process_karaoke()\n(~30-120s)", fillcolor="#fce4ec"];
hourglass [label="Button shows ⌛\nkaraokeProcessing=true", fillcolor="#e0f2f1"];
refresh [label="10s URL refresh\nfetchWatchData()", fillcolor="#e0f2f1"];
detect [label="instrumentals_url\nappears in JSON", shape=diamond, fillcolor="#fff8e1"];
autoswitch [label="Auto-switch to\ninstrumentals 🎤", fillcolor="#e8f5e9"];
click -> check;
check -> cycle [label="yes"];
check -> post [label="no (eligible)"];
post -> fork [label="200 {status: processing}"];
post -> hourglass;
fork -> process;
hourglass -> refresh [label="every 10s"];
refresh -> detect;
detect -> refresh [label="not yet"];
detect -> autoswitch [label="tracks ready"];
process -> detect [label="DB updated", style=dashed];
}
```
## Security Properties
- **Encryption at rest**: AES-256-CTR, unique key+IV per upload, key in ETS only
- **Single-use**: Upload deleted immediately after pool pulls it
- **TTL**: 15-minute expiry, swept every 60 seconds
- **Key loss on restart**: BEAM restart = all encryption keys gone = files undecryptable
- **Auth**: `X-Upload-Key` header + 128-bit random UUID per upload
- **Zerotrust network**: Container has no outbound network access
## Limits
| Limit | Value | Source |
|-------|-------|--------|
| Max file size | 3.698 GB (3,698,742,051 bytes) | `upload_store.ex @max_upload_bytes` |
| Memory per worker | ~64 KB | Disk-backed streaming at every stage |
| Concurrency | Per-key limit from `validate_keys()` | Unsandbox account tier |
| Retries | 3 attempts, exponential backoff (5s, 10s, 20s) | `karaoke.py` backfill |
| URL TTL | 15 minutes | Presigned URL expiry |
| Upload TTL | 15 minutes | API auto-cleanup |
| Container TTL | 300 seconds | `/execute` payload `ttl` field |
## S3 Key Structure
```
{shop_id}/products/{product_id}/
product # original media file
preview # preview (for sellable products)
thumbnail1-4 # thumbnails
instrumentals # karaoke: isolated instrumentals
vocals # karaoke: isolated vocals
```
## Product Metadata
```python
product.extensions = {
"product": "mp4", # original
"instrumentals": "mp4", # same ext for video, "wav" for audio
"vocals": "mp4",
}
product.file_bytes = {
"product": 15000000,
"instrumentals": 12000000,
"vocals": 8000000,
}
```
## Related Files
| File | Role |
|------|------|
| `lib/karaoke.py` | Core pipeline: download, upload, execute, process response |
| `lib/voxsplit.c` | Spectral mid-side vocal isolation (C, libc+libm only) |
| `lib/un.py` | Unsandbox API client, HMAC signing, key validation |
| `views/watch.py` | On-demand `POST /karaoke/{id}` endpoint + watch JSON |
| `views/product.py` | Upload-triggered karaoke processing |
| `views/content.py` | Content page karaoke URL generation |
| `static/js/watch.js` | Client: button states, on-demand trigger, auto-switch |
| `templates/snippets/related_content.j2` | Karaoke button HTML |
| `templates/content.j2` | `data-karaoke-eligible` attribute |
| `scripts/backfill_karaoke.py` | Standalone backfill script |