Commit 5f3f4d21 by PLN (Algolia)

update :D

parent f627f6ca
......@@ -10,6 +10,7 @@ ParVagues' main livecoding workspace.
- `backlog.md` — track backlog, setlists, gig prep notes
- `perf.sh` — performance-mode optimizer (CPU governor, etc.)
- `test.tidal` — scratchpad
- `armada/` — media/marketing toolbox (catalog, distribution, diffusion, gigs); see `armada/README.md`
## Sister projects
......@@ -22,10 +23,41 @@ ParVagues' main livecoding workspace.
## Conventions
- **One track = one standalone `.tidal` file** (e.g. `live/collab/raph/punkachien.tidal`).
There is no "set file" — a live set is performed by loading per-track files in sequence.
To find a track's score, look for its own `.tidal`; it always exists.
- The `.tidal` file lists a track's samples/synths and `dN` → orbit mapping, but **never
infer a sound's role/register/pitch from the name or code** — validate by analysis.
**EDA on stems (spectral centroid, fundamental, band energy, level, time-activity) is a
required first step of mastering.** (e.g. `meth_bass` is a noisy wobble, not a sub.)
- Channel/orbit mapping documented in `live/` patterns; LCXL CC map in memory
- Stems exported from Ardour as `Montreuil26_Tidal 01.wav` style (one per orbit)
- Visuals composited in post; OBS captures clean code only
- Rhadamanthe's master-chain notes: `live/collab/rhadamanthe/layering.tidal`
- **L'Armada — document as you go:** every achieved task gets a lean log entry in
`armada/tasks/` (format in `armada/tasks/README.md`). This salvage op is a
documentary in disguise — the logs are source material for blog posts & videos.
- **Be a good archivist of PLN's ear-feedback:** carefully capture every reaction to
recordings / performances / samples / transitions / mistakes (with track + timecode)
in `armada/tide-table/performance_notes.md`. This corpus feeds better sampling,
effect choices, and auto-mastering heuristics — log even small reactions, durably.
- Catalog distinction: a **gig/live-set** (CosmicFest, Opal…) is not a **track**;
a gig's `tracks.json` enumerates the tracks it contains.
- **Mastering ≠ metadata. Never invent gig metadata.** Audio/mastering work references
takes by **Take-ID + date** only; gig metadata (venue, set title, poster, lineup) is
canonical in `../../Web/www/next/content/lives/{year}/{slug}.md` + `/tracks.json`.
Verify there and cite the path — don't copy strings from intermediate scripts (e.g.
`split_bandcamp.py`'s ALBUM was wrong). If a fact isn't in the canonical source, ask.
## Design system (armada tools)
The armada UIs share one design language — **the Ship's Bridge**. Canonical:
`armada/PRODUCT.md` (strategic: register=product, principles) + `armada/DESIGN.md`
(visual: Google-Stitch format — brand magenta reserved, role-family colors for orbits,
lifecycle status, Geist + Geist-Mono). Managed with the **impeccable** skill
(`.claude/skills/impeccable`; run its commands from `armada/`). Stack for new UI =
**Vite + React + TS + Tailwind + shadcn** in `armada/ui/` (Node ≥18 — `nvm use 22`),
served static by `armada/serve.py` (LAN, Range-capable, for phone audition).
## See also
......
......@@ -36,6 +36,15 @@ you self-produce). Watch **CNM** *musiques actuelles* mobility/export aid for to
7. Reassess in 12 mo: ~50k monthly listeners → apply **AWAL**; JP traction →
**TuneCore Japan** + bilingual metadata.
## Tools
- **Le Sextant**`armada/ui/sextant.html` (Vite, `nvm use 22 && npm run dev`):
slider-driven lifetime cost/revenue simulator. Models PORT / KEEP / ADD across four
distributors (RouteNote Free baseline · Premium · CD Baby · DistroKid) + Bandcamp as
an independent layer, prefilled with the real catalog. Surfaces the DistroKid-vs-CD-
Baby break-even and reframes it as ownership-vs-price once a back-catalog exists.
Supersedes the static `compare.html`.
## Next
Feeds off `tide-table` gap analysis → drives the **release manifeste** (task #8):
......
---
log: 012
title: "Le Sextant the compass gets an engine"
date: 2026-06-05
task: "#14 Build distribution decision SPA (evolves #003)"
tags: [tooling, distribution, ux, economics]
shareable: true
---
## Cap (what & why)
The Decision Compass (#003) scored *fit* to priorities — useful, but it never
answered the money question: over a lifetime, what does each distributor cost to
**port** the back-catalog, **keep** it alive, and **add** new releases — and what's
left after their cut? PLN wanted a simulator, not a scorecard, and DistroKid back in
the running as a real option (not pre-rejected on the no-sub rule).
## Manœuvre (how)
Rebuilt as a slider-driven economic model in `armada/ui` (Vite multi-page: the
take-judge stays at `/`, the simulator at `/sextant.html`), Ship's Bridge tokens
reused as-is. A pure-function model (`model.ts`) expands catalog inputs into dated
releases, then computes PORT/KEEP/ADD cost + a decaying-spike revenue curve per
option. Four distributors — RouteNote Free (baseline), RouteNote Premium, CD Baby
(recommended), DistroKid — plus Bandcamp as an independent layer added on top of any
of them. Prefilled with the real catalog (11 singles + 1 album on DSP, 41 SoundCloud
backlog, ~6/yr forward). Hand-rolled SVG break-even chart — no chart dependency.
## Prise (findings / artifacts)
- `armada/ui/src/sextant/{model,data,format,Control,OptionRow,BreakEvenChart,Sextant}.tsx`
+ `sextant.html` entry; `compare.html` (#003) is superseded.
- At the real catalog: **RouteNote Premium goes net-negative** (−$12.7k vs baseline) —
renewing 53+ releases yearly outcosts the streams. **DistroKid wins on raw net**
(keeps 100%, $230/decade) but rents the shelf; **CD Baby is recommended** at
near-identical net because the catalog stays yours. **Bandcamp digital sales are a
rounding error** (~$132 over 10 yr) — its worth is ownership, not revenue.
## Sel (the shareable learning)
- The break-even cadence everyone quotes (CD Baby vs DistroKid) **collapses to zero
once you have a back-catalog to port** — owning-vs-renting stops being a price
question and becomes a "do they pull your music?" question. The slider makes that
flip visible; a static table hides it.
- First cut tied Bandcamp to one "Stack" option and priced it per-release-per-month —
which implied ~1,350 sales/yr across the catalog and made the Stack win by a
landslide. Wrong on both counts: Bandcamp is **independent** (runs alongside any
distributor, so it shifts every option equally and decides nothing), and real
digital sales are **a few a year, total**. Fixing the unit dissolved a fake winner.
- RouteNote Premium (cost) wanted to set the chart's axis and crush the CD-Baby/
DistroKid crossover. Scaling to the *non-outlier* max + an honest "off scale" flag
beat both log-scaling and special pleading.
- A superfan replaying a track weekly is **fan noise**, not a phenomenon — it lives
inside the long-tail floor, not a modelled spike. Said so in the UI; the instrument
never implies more precision than it has.
## Hameçon (hook)
"I tried to find the break-even between owning and renting my music. Turns out, the
moment you have a back-catalog, the math stops being about money."
## Sillage (what it unlocks)
PLN can run real scenarios (sleeping archive vs touring push) before committing to a
migration; the model's per-option net feeds the release manifeste's ordering.
......@@ -153,6 +153,33 @@ footer .sig span{color:var(--magenta)}
background:#ff52520f;color:var(--ink);font-size:14px;line-height:1.7}
.banner b{color:#ff7a7a}
/* ── conversion: platform pills + end CTA ─────────────────────────────────── */
.plat{display:flex;flex-wrap:wrap;gap:9px;align-items:center}
.plat.lede-row{margin-top:1.8rem}
.pl{display:inline-flex;align-items:center;gap:7px;font:500 13px/1 Geist,sans-serif;
color:var(--mute);text-decoration:none;padding:8px 13px;border-radius:9px;
border:1px solid var(--hairline);background:#ffffff06;transition:.18s ease}
.pl:hover{color:var(--ink);text-decoration:none;border-color:var(--pc,#fff);
background:#ffffff0e;transform:translateY(-1px)}
.pl .dot{width:8px;height:8px;border-radius:50%;background:var(--pc,#888)}
.cta{text-align:center;padding:16vh 0 12vh}
.cta h2{font-size:clamp(2rem,5vw,3.4rem);margin-bottom:1rem;text-wrap:balance}
.cta p{font-size:clamp(1.05rem,1.6vw,1.3rem);color:var(--mute);max-width:48ch;
margin:0 auto 2.4rem;text-wrap:pretty}
.ctabtns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:2.6rem}
.btn{display:inline-flex;align-items:center;gap:9px;font:600 15px/1 Geist,sans-serif;
text-decoration:none;padding:14px 22px;border-radius:11px;transition:.2s ease}
.btn.primary{background:var(--magenta);color:#0a0a0a}
.btn.primary:hover{filter:brightness(1.12);transform:translateY(-2px);text-decoration:none;
box-shadow:0 10px 34px #d900ff44}
.btn.ghost{border:1px solid var(--hairline);color:var(--ink);background:#ffffff08}
.btn.ghost:hover{border-color:var(--ink);text-decoration:none;transform:translateY(-2px)}
.cta .plat{justify-content:center}
footer .srcs{margin-top:1.4rem;display:flex;flex-wrap:wrap;gap:6px 14px;
font-size:.76rem;color:var(--faint)}
footer .srcs .src b{color:var(--mute);font-weight:500}
footer .srcs .coming{color:var(--faint);opacity:.7}
/* tooltip — the "what am I looking at" layer */
.tip{position:fixed;z-index:60;pointer-events:none;opacity:0;
transition:opacity .12s ease;max-width:280px;
......@@ -242,6 +269,36 @@ function onMove(e){
}
document.addEventListener("mousemove",onMove);
/* ── conversion config (AUTHORED) ─────────────────────────────────────────────
Fill ONLY confirmed canonical URLs. A platform with an empty URL is hidden, so
nothing broken ever ships. siteLives = public base for gig pages (each gig page
carries the recording), e.g. "https://parvagues.org/lives". Future: source these
from a links.json with provenance once more are confirmed. */
const LINKS={
bandcamp:"https://parvagues.bandcamp.com", // confirmed (live albums + tracks)
spotify:"", // TODO confirm ParVagues artist page
youtube:"", // TODO
soundcloud:"", // TODO
instagram:"", // TODO
siteLives:"", // TODO base URL for gig pages → enables per-gig links
};
const PLATFORMS=[ // brand color = recognizability; chrome stays restrained until hover
{k:"bandcamp",label:"Bandcamp",c:"#1da0c3"},
{k:"spotify",label:"Spotify",c:"#1db954"},
{k:"youtube",label:"YouTube",c:"#ff3d3d"},
{k:"soundcloud",label:"SoundCloud",c:"#ff7733"},
{k:"instagram",label:"Instagram",c:"#e1306c"},
];
function platformRow(extraCls){
const have=PLATFORMS.filter(p=>LINKS[p.k]);
if(!have.length)return"";
return `<div class="plat ${extraCls||""}">`+have.map(p=>
`<a class="pl" href="${LINKS[p.k]}" target="_blank" rel="noopener" style="--pc:${p.c}">`
+`<span class="dot" style="background:${p.c}"></span>${esc(p.label)}</a>`).join("")+`</div>`;
}
/* public gig-page URL for a slug like "2025/cosmicfest" (only when siteLives set) */
function gigUrl(slug){return LINKS.siteLives?LINKS.siteLives.replace(/\/$/,"")+"/"+slug:null;}
/* ── data load: inlined block first (deploy), else fetch (dev/serve) ──────── */
function embedded(id){const e=document.getElementById(id);
if(e&&e.textContent.trim()){try{return JSON.parse(e.textContent)}catch(_){}}return null;}
......
# Corner-B EDA Grounding — plan (the 1/63 gap)
_Drafted 2026-06-06. Goal: trustable, audio-grounded EDA across the catalog so
corner B stops being "metadata prior; nothing verified." Surfaced live by the
triangle's `EDA coverage N/63`._
## The reframe — don't ground 63, ground ~9
Most tracks live inside a few big **SET** takes, so corner B for the **40 recorded
tracks** is covered by only **12 distinct candidate takes** (1 done = Take89).
Strip the date-join noise (Take63 `empty`, Take64 `sketch` — same ENSAD date, no
real audio) and the real targets are ~**9 takes**:
| take | type | #tracks | note |
|---|---|--:|---|
| Take89 | SET | 15 | ✅ done (Montreuil) |
| Take70 | SET | 14 | CosmicFest v0 |
| Take65 | SET | 10 | ENSAD |
| Take66 | SET | 10 | ENSAD |
| Take20 | SET | 9 | CCC Live |
| Take36 | SET | 5 | 38C3 Toilet |
| Take18/19/21/80 | track | 9 | La French Stack / CCC / Bunker |
Grounding these climbs `EDA coverage` from 1 → ~10 takes and lights corner B for
all 40 recorded tracks. The other ~50 takes are lower priority (sketches, empties,
unreleased sets) — ground later, by release value.
## Reachability — freebox is SSOT, MINIMIZE seeks (don't avoid it)
The freebox disk is the single source of truth for stems/masters; we work with it.
The discipline is **seek-minimizing**: plan a fetch *manifest*, pull everything for
the priority takes in ONE batched pass, treat local files as a cache, then EDA
offline. Never per-lookup `stat` the disk.
- **Already cached locally:** Take89 full (EDA done); proxies Take87/89; mix Take35;
**31 Ardour export stems** (recent set(s)) → analyze now, no fetch needed.
- **On freebox SSOT:** older takes' per-orbit stems (`export_archive_pre2026`) +
masters (`Prod/…`). Resolve what's needed for the ~9 priority takes, fetch once.
## Staged approach
- **Stage 0 — EDA-status ledger** (cheap, no freebox): per priority take, classify
reachability `{local-stems | local-proxy | freebox-stems | none}`; map the 31
local stems → their take(s). Output feeds `eda_index()` and the triangle.
- **Stage 1 — `build_eda.py {take}`**: generalize `build_player_data`/`audio_lens`
into a per-take emitter → `eda_{take}.json` (dur, level, broad centroid, per-orbit
activity). Run on every locally-reachable priority take first. Coverage jumps.
- **Stage 2 — one batched freebox pass**: transcode lightweight proxies for the
*remaining priority takes only*, then Stage-1 EDA locally.
- **Stage 3 — full per-orbit lens**: only for ship-intent takes, using stems
(`audio_lens` family/centroid/bands). Prioritize by release value, not chronology.
## Mechanical gate (parsers-over-copy, tested)
- A `TakeEDA` pydantic model; each `eda_{take}.json` validated on emit.
- `eda_index()` already auto-discovers `eda_*.json` → coverage rises with no wiring.
- A test asserts coverage is monotonic (never silently regresses) and that each
grounded take's orbits reconcile with its `.tidal` score (corner A ⋈ B).
- Add `build_eda` as a `tide.py` step.
## Refinement spotted en route
`takes_for_gig` should drop `empty`/`sketch` take-types from candidate links
(Take63/64 polluting the priority list) — cheap fix in `build_catalog_view`.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Le Sextant · L'Armada</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/sextant.tsx"></script>
</body>
</html>
......@@ -50,3 +50,44 @@
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
/* ── Sextant range control — track fill driven by inline --fill (0–100%) ── */
.sx-range {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--color-ink) 0%,
var(--color-ink) var(--fill, 0%),
var(--color-overlay) var(--fill, 0%),
var(--color-overlay) 100%
);
cursor: pointer;
outline: none;
}
.sx-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--color-ink);
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 1px var(--color-hairline);
transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
}
.sx-range::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--color-ink);
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 1px var(--color-hairline);
}
.sx-range:hover::-webkit-slider-thumb { transform: scale(1.12); }
.sx-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-melodic) 60%, transparent); }
.sx-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-melodic) 60%, transparent); }
.sx-range::-moz-range-track { height: 6px; border-radius: 999px; background: var(--color-overlay); }
.sx-range::-moz-range-progress { height: 6px; border-radius: 999px; background: var(--color-ink); }
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import './index.css'
import Sextant from './sextant/Sextant.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Sextant />
</StrictMode>,
)
import { useMemo } from 'react'
import type { BreakEvenPoint } from './model'
import { money } from './format'
// This chart answers one question: own (CD Baby) vs rent-flat (DistroKid).
// Stack tracks CD Baby exactly on cost, so it's folded into that line. RouteNote
// Premium is deliberately omitted — its cost runs 5–10× higher and would crush
// the crossover's resolution; its story is told in its card instead.
interface Line {
id: string
label: string
color: string
dash?: boolean
}
const LINES: Line[] = [
{ id: 'distrokid', label: 'DistroKid (flat)', color: 'var(--color-melodic)' },
{ id: 'cdbaby', label: 'CD Baby (per release)', color: 'var(--color-tops)' },
{ id: 'routenote-free', label: 'RouteNote Free', color: 'var(--color-ink-faint)', dash: true },
]
interface Props {
sweep: BreakEvenPoint[]
currentCadence: number
crossover: number | null
}
const W = 640
const H = 280
const PAD = { t: 16, r: 16, b: 36, l: 56 }
export function BreakEvenChart({ sweep, currentCadence, crossover }: Props) {
const { paths, maxX, xOf, yOf, ticksY } = useMemo(() => {
const maxX = sweep.length - 1
const maxCost = Math.max(
1,
...sweep.flatMap((p) => LINES.map((l) => p.costs[l.id] ?? 0)),
)
// round the axis up to a clean ceiling
const mag = Math.pow(10, Math.floor(Math.log10(maxCost)))
const maxY = Math.ceil(maxCost / mag) * mag
const xOf = (cad: number) => PAD.l + (cad / maxX) * (W - PAD.l - PAD.r)
const yOf = (cost: number) => H - PAD.b - (cost / maxY) * (H - PAD.t - PAD.b)
const paths = LINES.map((l) => ({
...l,
d: sweep
.map((p, i) => `${i === 0 ? 'M' : 'L'}${xOf(p.cadence).toFixed(1)},${yOf(p.costs[l.id] ?? 0).toFixed(1)}`)
.join(' '),
}))
const ticksY = [0, 0.25, 0.5, 0.75, 1].map((f) => f * maxY)
return { paths, maxX, xOf, yOf, ticksY }
}, [sweep])
return (
<figure className="m-0">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-auto w-full"
role="img"
aria-label="Lifetime distributor cost as a function of releases per year"
>
{/* y grid + labels */}
{ticksY.map((t) => (
<g key={t}>
<line x1={PAD.l} x2={W - PAD.r} y1={yOf(t)} y2={yOf(t)} stroke="var(--color-hairline)" />
<text x={PAD.l - 8} y={yOf(t) + 4} textAnchor="end" className="fill-[var(--color-ink-faint)] text-[10px]">
{money(t)}
</text>
</g>
))}
{/* x labels */}
{sweep
.filter((_, i) => i % 4 === 0)
.map((p) => (
<text
key={p.cadence}
x={xOf(p.cadence)}
y={H - PAD.b + 18}
textAnchor="middle"
className="fill-[var(--color-ink-faint)] text-[10px]"
>
{p.cadence}
</text>
))}
<text
x={(PAD.l + W - PAD.r) / 2}
y={H - 4}
textAnchor="middle"
className="fill-[var(--color-ink-muted)] text-[10px]"
>
releases per year →
</text>
{/* crossover marker: where DistroKid overtakes CD Baby on cost */}
{crossover != null && crossover > 0 && crossover <= maxX ? (
<g>
<line
x1={xOf(crossover)}
x2={xOf(crossover)}
y1={PAD.t}
y2={H - PAD.b}
stroke="var(--color-ink-muted)"
strokeDasharray="3 3"
/>
<text x={xOf(crossover) + 6} y={PAD.t + 12} className="fill-[var(--color-ink-muted)] text-[10px]">
break-even ≈ {crossover}/yr
</text>
</g>
) : (
<text x={PAD.l + 8} y={PAD.t + 12} className="fill-[var(--color-ink-muted)] text-[10px]">
{crossover === 0 ? 'DistroKid cheaper at every cadence' : 'CD Baby cheaper at every cadence'}
</text>
)}
{/* current cadence: you are here */}
<g>
<line
x1={xOf(currentCadence)}
x2={xOf(currentCadence)}
y1={PAD.t}
y2={H - PAD.b}
stroke="var(--color-magenta)"
strokeWidth={1.5}
/>
<circle cx={xOf(currentCadence)} cy={PAD.t} r={3} fill="var(--color-magenta)" />
</g>
{/* lines */}
{paths.map((p) => (
<path
key={p.id}
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={2}
strokeDasharray={p.dash ? '4 4' : undefined}
strokeLinejoin="round"
/>
))}
</svg>
<figcaption className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-ink-muted">
{LINES.map((l) => (
<span key={l.id} className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="inline-block h-0.5 w-4"
style={{ background: l.color, opacity: l.dash ? 0.7 : 1 }}
/>
{l.label}
</span>
))}
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="inline-block h-3 w-0.5 bg-magenta" />
your cadence
</span>
</figcaption>
</figure>
)
}
import { useId } from 'react'
interface SliderProps {
label: string
value: number
min: number
max: number
step?: number
unit?: string
format?: (v: number) => string
hint?: string
onChange: (v: number) => void
}
/** Labelled range with a live tabular readout. The whole row is the control;
* the track fill mirrors the value so the eye reads position without the number. */
export function Slider({ label, value, min, max, step = 1, unit, format, hint, onChange }: SliderProps) {
const id = useId()
const pct = max === min ? 0 : ((value - min) / (max - min)) * 100
const shown = format ? format(value) : `${value}${unit ?? ''}`
return (
<div className="group">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor={id} className="text-xs font-medium text-ink-muted group-focus-within:text-ink">
{label}
</label>
<span className="font-mono text-sm text-ink tnum">{shown}</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sx-range mt-2 w-full"
style={{ '--fill': `${pct}%` } as React.CSSProperties}
/>
{hint && <p className="mt-1 text-[11px] leading-snug text-ink-faint">{hint}</p>}
</div>
)
}
interface StepperProps {
label: string
value: number
min?: number
max?: number
step?: number
unit?: string
onChange: (v: number) => void
}
/** Discrete +/− for exact integer counts (catalog sizes) where dragging is fiddly. */
export function Stepper({ label, value, min = 0, max = 999, step = 1, unit, onChange }: StepperProps) {
const clamp = (v: number) => Math.max(min, Math.min(max, v))
return (
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-ink-muted">{label}</span>
<div className="flex items-center gap-1">
<button
type="button"
aria-label={`decrease ${label}`}
onClick={() => onChange(clamp(value - step))}
disabled={value <= min}
className="grid size-7 place-items-center rounded-sm border border-hairline bg-raised
text-ink-muted transition-colors hover:bg-overlay hover:text-ink
focus-visible:outline focus-visible:outline-2 focus-visible:outline-melodic
disabled:opacity-30"
>
</button>
<span className="w-12 text-center font-mono text-sm text-ink tnum">
{value}
{unit && <span className="text-ink-faint">{unit}</span>}
</span>
<button
type="button"
aria-label={`increase ${label}`}
onClick={() => onChange(clamp(value + step))}
disabled={value >= max}
className="grid size-7 place-items-center rounded-sm border border-hairline bg-raised
text-ink-muted transition-colors hover:bg-overlay hover:text-ink
focus-visible:outline focus-visible:outline-2 focus-visible:outline-melodic
disabled:opacity-30"
>
+
</button>
</div>
</div>
)
}
interface ToggleProps {
label: string
checked: boolean
hint?: string
onChange: (v: boolean) => void
}
/** Switch for an on/off layer (e.g. running Bandcamp alongside the distributor). */
export function Toggle({ label, checked, hint, onChange }: ToggleProps) {
return (
<div>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex w-full items-center justify-between gap-3 text-left"
>
<span className="text-xs font-medium text-ink-muted">{label}</span>
<span
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors ${
checked ? 'border-melodic/60 bg-melodic/30' : 'border-hairline bg-overlay'
}`}
>
<span
className={`inline-block size-3.5 rounded-full bg-ink transition-transform ${
checked ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</span>
</button>
{hint && <p className="mt-1 text-[11px] leading-snug text-ink-faint">{hint}</p>}
</div>
)
}
/** A grouped block of controls under a quiet section heading. */
export function Group({ title, glyph, children }: { title: string; glyph: string; children: React.ReactNode }) {
return (
<section className="space-y-4">
<h3 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
<span aria-hidden className="text-ink-muted">
{glyph}
</span>
{title}
</h3>
{children}
</section>
)
}
import { Infinity as InfinityIcon, Lock, AlertTriangle, Star } from 'lucide-react'
import type { OptionResult } from './model'
import { money, signedMoney } from './format'
interface Props {
r: OptionResult
isWinner: boolean
maxCost: number
maxProfit: number
}
// PORT / KEEP / ADD share one horizontal cost bar so the three buckets read as
// one lifetime total, with the split shown by segment.
const SEGMENTS = [
{ key: 'port' as const, label: 'Port', color: 'var(--color-melodic)' },
{ key: 'keep' as const, label: 'Keep', color: 'var(--color-percs)' },
{ key: 'add' as const, label: 'Add', color: 'var(--color-tops)' },
]
export function OptionRow({ r, isWinner, maxCost, maxProfit }: Props) {
const { opt, cost, profit, vsBaseline } = r
const offScale = cost.total > maxCost + 0.5 // outlier (Premium): bar maxes out
const costW = maxCost > 0 ? Math.min(100, (cost.total / maxCost) * 100) : 0
const profitW = maxProfit > 0 ? (Math.max(0, profit) / maxProfit) * 100 : 0
return (
<article
className={`rounded-lg border bg-raised p-4 transition-colors ${
isWinner ? 'border-magenta/60 bg-magenta/[0.04]' : 'border-hairline'
}`}
>
<header className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold text-ink">{opt.name}</h3>
{opt.recommended && (
<span className="inline-flex items-center gap-1 rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium text-tops">
<Star size={10} /> recommended
</span>
)}
{opt.baseline && (
<span className="rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium text-ink-faint">
baseline
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-ink-faint">
{opt.ownsForever ? (
<span title="Music stays up with no further payment" className="inline-flex items-center gap-1 text-[11px] text-ready">
<Lock size={11} /> owned
</span>
) : (
<span title="Music is pulled from DSPs if you stop paying" className="inline-flex items-center gap-1 text-[11px] text-status-wip">
<AlertTriangle size={11} /> rented
</span>
)}
</div>
</header>
<p className="mt-1 text-xs leading-snug text-ink-muted">{opt.note}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{opt.facts.map((f) => (
<span key={f} className="rounded-sm bg-overlay px-1.5 py-0.5 font-mono text-[10px] text-ink-muted tnum">
{f}
</span>
))}
</div>
{/* Cost bar: PORT · KEEP · ADD */}
<div className="mt-4">
<div className="mb-1 flex items-baseline justify-between text-[11px] text-ink-faint">
<span>lifetime cost{offScale && <span className="ml-1 text-status-wip">· off scale</span>}</span>
<span className="font-mono text-ink tnum">{money(cost.total)}</span>
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-overlay" style={{ width: `${Math.max(costW, 2)}%` }}>
{SEGMENTS.map((s) =>
cost[s.key] > 0 ? (
<div
key={s.key}
style={{ flexGrow: cost[s.key], background: s.color }}
title={`${s.label}: ${money(cost[s.key])}`}
/>
) : null,
)}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-ink-faint">
{SEGMENTS.map((s) => (
<span key={s.key} className="inline-flex items-center gap-1">
<span aria-hidden className="inline-block size-1.5 rounded-full" style={{ background: s.color }} />
{s.label} <span className="font-mono text-ink-muted tnum">{money(cost[s.key])}</span>
</span>
))}
</div>
</div>
{/* Net + delta vs baseline */}
<div className="mt-4 flex items-end justify-between border-t border-hairline pt-3">
<div>
<div className="text-[11px] text-ink-faint">net over horizon</div>
<div className="font-mono text-lg text-ink tnum">{money(profit)}</div>
<div className="mt-1 h-1 w-28 overflow-hidden rounded-full bg-overlay">
<div className="h-full rounded-full bg-ready/70" style={{ width: `${profitW}%` }} />
</div>
</div>
<div className="text-right">
<div className="text-[11px] text-ink-faint">vs RouteNote Free</div>
<div
className={`font-mono text-lg tnum ${
vsBaseline > 0.5 ? 'text-ready' : vsBaseline < -0.5 ? 'text-blocked' : 'text-ink-muted'
}`}
>
{signedMoney(vsBaseline)}
</div>
</div>
</div>
</article>
)
}
export { InfinityIcon }
import { useEffect, useMemo, useState } from 'react'
import { Anchor, RotateCcw, Compass } from 'lucide-react'
import {
simulate,
breakEvenSweep,
distrokidCrossover,
type CatalogInputs,
type RevenueInputs,
} from './model'
import { PARVAGUES_CATALOG, MODEST_REVENUE } from './data'
import { Slider, Stepper, Group, Toggle } from './Control'
import { OptionRow } from './OptionRow'
import { BreakEvenChart } from './BreakEvenChart'
import { money, compact } from './format'
const STORE = 'sextant_inputs_v3'
interface State {
cat: CatalogInputs
rev: RevenueInputs
}
function load(): State {
try {
const s = JSON.parse(localStorage.getItem(STORE) || '')
if (s?.cat && s?.rev) return s
} catch {
/* fall through to defaults */
}
return { cat: { ...PARVAGUES_CATALOG }, rev: { ...MODEST_REVENUE } }
}
export default function Sextant() {
const [{ cat, rev }, setState] = useState<State>(load)
useEffect(() => {
localStorage.setItem(STORE, JSON.stringify({ cat, rev }))
}, [cat, rev])
const setCat = (patch: Partial<CatalogInputs>) => setState((s) => ({ ...s, cat: { ...s.cat, ...patch } }))
const setRev = (patch: Partial<RevenueInputs>) => setState((s) => ({ ...s, rev: { ...s.rev, ...patch } }))
const reset = () => setState({ cat: { ...PARVAGUES_CATALOG }, rev: { ...MODEST_REVENUE } })
const { results, counts, bcRevenue } = useMemo(() => simulate(cat, rev), [cat, rev])
const sweep = useMemo(() => breakEvenSweep(cat), [cat])
const crossover = useMemo(() => distrokidCrossover(cat), [cat])
const ranked = useMemo(() => [...results].sort((a, b) => b.profit - a.profit), [results])
const winner = ranked[0]
// Scale cost bars to the non-premium options. RouteNote Premium is a 5–10×
// outlier; letting it set the scale would crush every other bar to a sliver.
const maxCost = Math.max(...results.filter((r) => r.opt.id !== 'routenote-premium').map((r) => r.cost.total), 1)
const maxProfit = Math.max(...results.map((r) => r.profit), 1)
const cdbaby = results.find((r) => r.opt.id === 'cdbaby')!
const distrokid = results.find((r) => r.opt.id === 'distrokid')!
const aheadOfCDBaby = cat.cadencePerYear >= (crossover ?? Infinity)
return (
<div className="mx-auto min-h-svh max-w-7xl px-4 py-5 lg:px-6">
<header className="flex flex-wrap items-end justify-between gap-3 border-b border-hairline pb-4">
<div>
<p className="flex items-center gap-1.5 text-[11px] uppercase tracking-widest text-ink-faint">
<Anchor size={12} /> L&apos;Armada · manifeste
</p>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Compass size={22} className="text-ink-muted" /> Le Sextant
</h1>
<p className="mt-0.5 max-w-prose text-xs text-ink-faint">
Where should the catalog live? Tune the catalog, cadence, and what a stream is worth. Every
cost and cut is a sourced fact; every play count is your assumption.
</p>
</div>
<button
onClick={reset}
className="flex items-center gap-1.5 rounded-md bg-raised px-3 py-1.5 text-xs font-medium
text-ink-muted transition-colors hover:bg-overlay hover:text-ink"
>
<RotateCcw size={13} /> Reset to ParVagues
</button>
</header>
{/* Verdict strip */}
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-magenta/40 bg-magenta/[0.05] p-4">
<div className="text-[11px] uppercase tracking-wide text-ink-faint">Best net over {cat.horizonYears} yr</div>
<div className="mt-0.5 text-lg font-semibold text-ink">{winner.opt.name}</div>
<div className="mt-1 font-mono text-sm text-ready tnum">
{money(winner.profit)} net · {money(winner.cost.total)} cost
</div>
{bcRevenue > 0 && (
<div className="mt-1 text-[11px] text-ink-faint">
incl. {money(bcRevenue)} Bandcamp layer — added to every option alike
</div>
)}
</div>
<div className="rounded-lg border border-hairline bg-raised p-4">
<div className="text-[11px] uppercase tracking-wide text-ink-faint">DistroKid vs CD Baby</div>
<div className="mt-0.5 text-sm leading-snug text-ink-muted">
{crossover == null ? (
<>CD Baby stays cheaper at every cadence in range — owning beats renting outright.</>
) : crossover === 0 ? (
<>
<span className="text-melodic">DistroKid is cheaper from day one</span> — porting your back-catalog to
CD Baby already outcosts a decade of flat fees. The real question is ownership, not price.
</>
) : (
<>
Break-even at <span className="font-mono text-ink">{crossover}</span> releases/yr. You plan{' '}
<span className="font-mono text-ink">{cat.cadencePerYear}</span>{' '}
<span className={aheadOfCDBaby ? 'text-melodic' : 'text-tops'}>
{aheadOfCDBaby ? 'DistroKid wins on cost' : 'CD Baby wins on cost'}
</span>
.
</>
)}
</div>
</div>
<div className="rounded-lg border border-hairline bg-raised p-4">
<div className="text-[11px] uppercase tracking-wide text-ink-faint">Catalog modelled</div>
<div className="mt-0.5 font-mono text-sm text-ink tnum">
{counts.total} releases · {counts.singles} singles / {counts.albums} albums
</div>
<div className="mt-1 text-[11px] text-ink-faint">
{counts.existing} existing · {counts.backlog} backlog · {counts.forward} forward
</div>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[320px_1fr]">
{/* ── Control rail ── */}
<aside className="space-y-7 lg:sticky lg:top-5 lg:self-start">
<Group title="Existing catalog" glyph="◆">
<p className="-mt-1 text-[11px] text-ink-faint">Already on RouteNote/DSP. Frozen since 2024.</p>
<Stepper label="Singles" value={cat.existingSingles} max={200} onChange={(v) => setCat({ existingSingles: v })} />
<Stepper label="Albums / EPs" value={cat.existingAlbums} max={50} onChange={(v) => setCat({ existingAlbums: v })} />
</Group>
<Group title="Backlog to release" glyph="≋">
<p className="-mt-1 text-[11px] text-ink-faint">SoundCloud tracks not yet on any DSP.</p>
<Slider label="Tracks to release" value={cat.backlogTracks} min={0} max={80} unit="" onChange={(v) => setCat({ backlogTracks: v })} />
<Slider
label="Bundle into EPs"
value={cat.backlogTracksPerEp}
min={1}
max={8}
format={(v) => (v <= 1 ? 'all singles' : `${v} tracks/EP`)}
hint="Fewer deliveries = lower per-release fees on CD Baby."
onChange={(v) => setCat({ backlogTracksPerEp: v })}
/>
</Group>
<Group title="Forward cadence" glyph="↗">
<Slider label="Releases per year" value={cat.cadencePerYear} min={0} max={24} onChange={(v) => setCat({ cadencePerYear: v })} />
<Slider
label="Album share"
value={cat.cadenceAlbumShare}
min={0}
max={1}
step={0.05}
format={(v) => `${Math.round(v * 100)}% albums`}
onChange={(v) => setCat({ cadenceAlbumShare: v })}
/>
<Slider label="Horizon" value={cat.horizonYears} min={1} max={25} unit=" yr" onChange={(v) => setCat({ horizonYears: v })} />
</Group>
<Group title="Revenue assumptions" glyph="▲">
<Slider label="Peak streams / release · mo" value={rev.peakStreams} min={50} max={5000} step={50} format={compact} onChange={(v) => setRev({ peakStreams: v })} />
<Slider
label="Long-tail floor / mo"
value={rev.floorStreams}
min={0}
max={1000}
step={10}
format={compact}
hint="The loyal-listener tail. A superfan on weekly repeat lives in here — noise, not a modelled spike."
onChange={(v) => setRev({ floorStreams: v })}
/>
<Slider label="Spike half-life" value={rev.halfLifeMonths} min={1} max={36} unit=" mo" onChange={(v) => setRev({ halfLifeMonths: v })} />
<Slider label="Payout / stream" value={rev.perStream} min={0.001} max={0.01} step={0.0005} format={(v) => `$${v.toFixed(4)}`} onChange={(v) => setRev({ perStream: v })} />
</Group>
<Group title="Bandcamp layer" glyph="◇">
<Toggle
label="Run Bandcamp alongside"
checked={rev.bcLayer}
hint="Independent of the distributor — adds the same revenue to every option, so it never decides the comparison."
onChange={(v) => setRev({ bcLayer: v })}
/>
{rev.bcLayer && (
<>
<Slider
label="Digital sales / year"
value={rev.bcSalesPerYear}
min={0}
max={60}
format={(v) => `${v}/yr · whole catalog`}
hint="Across the entire catalog, not per release. For a niche catalog this is genuinely small."
onChange={(v) => setRev({ bcSalesPerYear: v })}
/>
<Slider label="Bandcamp price" value={rev.bcPrice} min={1} max={20} format={money} onChange={(v) => setRev({ bcPrice: v })} />
</>
)}
</Group>
</aside>
{/* ── Results console ── */}
<main className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{ranked.map((r) => (
<OptionRow key={r.opt.id} r={r} isWinner={r.opt.id === winner.opt.id} maxCost={maxCost} maxProfit={maxProfit} />
))}
</div>
<section className="rounded-lg border border-hairline bg-raised p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-semibold text-ink">The break-even, drawn</h2>
<p className="text-xs text-ink-muted">
CD Baby pays per release; DistroKid is flat. Where the teal crosses the blue, renting beats owning on
pure cost.
</p>
</div>
<BreakEvenChart sweep={sweep} currentCadence={cat.cadencePerYear} crossover={crossover} />
<p className="mt-3 text-[11px] leading-snug text-ink-faint">
At your settings: CD Baby costs <span className="font-mono text-ink-muted">{money(cdbaby.cost.total)}</span> over{' '}
{cat.horizonYears} yr and stays up forever; DistroKid costs{' '}
<span className="font-mono text-ink-muted">{money(distrokid.cost.total)}</span> but pulls everything the day
you stop paying. Cost is one axis — ownership is the other.
</p>
</section>
<footer className="border-t border-hairline pt-3 text-[11px] leading-relaxed text-ink-faint">
<span className="text-ink-muted">Facts</span> (fees, cuts) from{' '}
<code className="text-ink-muted">armada/manifeste/research-2026-distribution.md</code>.{' '}
<span className="text-ink-muted">Assumptions</span> (plays, sales) are yours and persist locally. Streaming
revenue is modelled as a per-release spike decaying to a flat long-tail floor; albums earn ~2.5× a single.
Bandcamp is an independent layer — the same revenue on top of whichever distributor you pick, so it shifts
every option equally and never decides the comparison; its real worth is ownership and the fan
relationship, not the digital-sales line. The model is a compass, not a forecast.
</footer>
</main>
</div>
</div>
)
}
import type { CatalogInputs, RevenueInputs } from './model'
// Prefill = real ParVagues catalog (armada/tide-table, 2026-06).
// · 25 distinct titles on DSP/RouteNote, frozen since 2024-10-04:
// modelled as 11 standalone singles + 1 album (live@cosmicfest, 14 tracks).
// · 41 SoundCloud tracks not on any DSP — the release backlog (gap-report.md).
// · Fresh 2026 Montreuil masters ready → a realistic ~6 releases/yr forward.
export const PARVAGUES_CATALOG: CatalogInputs = {
existingSingles: 11,
existingAlbums: 1,
backlogTracks: 41,
backlogTracksPerEp: 1, // all singles by default; bundle into EPs with the slider
cadencePerYear: 6,
cadenceAlbumShare: 0.2,
horizonYears: 10,
}
// Modest / realistic baseline for a niche livecoding artist. All user-tunable.
// floorStreams is the loyal-listener long tail (one fan replaying weekly is noise
// inside this floor, not a modelled spike).
export const MODEST_REVENUE: RevenueInputs = {
peakStreams: 700,
floorStreams: 200,
halfLifeMonths: 9,
perStream: 0.0035,
// Bandcamp is a separate layer you can run alongside any distributor. Digital
// sales for a niche livecoding catalog are genuinely tiny — a few a year total,
// not per release. Its real worth is ownership + the fan relationship, not this number.
bcLayer: true,
bcSalesPerYear: 3,
bcPrice: 5,
}
/** USD with no cents above $100, cents below. Signed variant for deltas. */
export function money(v: number): string {
const abs = Math.abs(v)
const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2
return `$${v.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`
}
export function signedMoney(v: number): string {
if (Math.abs(v) < 0.5) return '±$0'
return `${v > 0 ? '+' : '−'}${money(Math.abs(v))}`
}
/** Compact counts: 1234 → 1.2k, 1_200_000 → 1.2M */
export function compact(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}k`
return `${Math.round(v)}`
}
// Le Sextant — distribution revenue/cost model.
//
// Answers one question: over a lifetime, what does each distributor cost you to
// PORT what you have, KEEP it alive, and ADD new releases — and what's left after
// their cut? RouteNote-free is the baseline everything is measured against.
//
// All facts (fees, cuts) are sourced from armada/manifeste/research-2026-distribution.md.
// All assumptions (streams, sales) are user-tunable. The model never implies more
// precision than the data has: the long tail is a smooth floor, not simulated fan-by-fan.
export type Kind = 'single' | 'album'
export type Bucket = 'existing' | 'backlog' | 'forward'
export interface CatalogInputs {
existingSingles: number // standalone tracks already on RouteNote/DSP
existingAlbums: number // albums/EPs already on RouteNote/DSP (one delivery each)
backlogTracks: number // SoundCloud tracks not yet on any DSP
backlogTracksPerEp: number // 1 = release all as singles; >1 = bundle into EPs
cadencePerYear: number // forward releases per year
cadenceAlbumShare: number // 0..1 — fraction of forward releases that are albums/EPs
remastersPerYear: number // existing releases re-mastered & re-delivered per year
horizonYears: number // lifetime horizon
}
export interface RevenueInputs {
peakStreams: number // streams in a release's first month on a DSP
floorStreams: number // long-tail monthly floor (the loyal-listener tail)
halfLifeMonths: number // months for the spike above floor to halve
perStream: number // payout per stream (USD)
bcLayer: boolean // run Bandcamp alongside the distributor (independent layer)
bcSalesPerYear: number // Bandcamp digital sales per year, across the whole catalog
bcPrice: number // Bandcamp unit price (USD, EUR parity assumed)
}
// Bandcamp's cut: 15% digital (10% after $5k/yr), 0% on the 8 Bandcamp Fridays.
// Blended to ~12% effective → keep ~88%.
export const BC_KEEP = 0.88
// Freedom & control — qualitative, sourced from 2026 distributor help docs.
// You keep 100% of masters with all of them (non-exclusive); these are the axes
// that actually differ.
export type FreedomLevel = 'good' | 'mixed' | 'bad'
export interface FreedomFact {
level: FreedomLevel
text: string
}
export interface Freedom {
remaster: FreedomFact // swap audio on an already-live release
edit: FreedomFact // change title/art/metadata after release
pull: FreedomFact // take a release down
unpaid: FreedomFact // permanence: what happens if you stop paying
}
export interface Option {
id: string
name: string
short: string
recommended?: boolean
baseline?: boolean
// cost facts (USD)
oneTimeSingle: number
oneTimeAlbum: number
annualSingle: number // recurring per-release renewal (RouteNote Premium)
annualAlbum: number
flatAnnual: number // platform subscription, unlimited releases (DistroKid)
flatAnnualWithRemaster?: number // higher tier needed for in-place audio swap (DistroKid Ultimate)
redeliverCost: number // cost to push a remaster of one existing release (0 = free / in-place)
// economics
keepFraction: number // share of streaming revenue kept after their cut
ownsForever: boolean // music stays up with no further payment
pullsIfUnpaid: boolean // music removed from DSPs if you stop paying
freedom: Freedom
note: string
facts: string[] // shown verbatim in the option row
}
// Bandcamp the layer, on the same freedom axes — the free-replace outlier.
export const BANDCAMP_FREEDOM: Freedom = {
remaster: { level: 'good', text: 'Replace the file in place, free, anytime' },
edit: { level: 'good', text: 'Fully editable, free' },
pull: { level: 'good', text: 'Free, instant' },
unpaid: { level: 'good', text: "It's your own store — nothing to lapse" },
}
// Albums earn more than singles but not linearly (not every track gets equal plays).
const ALBUM_STREAM_MULT = 2.5
export const OPTIONS: Option[] = [
{
id: 'routenote-free',
name: 'RouteNote · Free',
short: 'baseline',
baseline: true,
oneTimeSingle: 0,
oneTimeAlbum: 0,
annualSingle: 0,
annualAlbum: 0,
flatAnnual: 0,
redeliverCost: 0, // re-upload is free; track-link to keep plays
keepFraction: 0.85,
ownsForever: true,
pullsIfUnpaid: false,
freedom: {
remaster: { level: 'mixed', text: 'Re-upload free; track-link to keep plays' },
edit: { level: 'good', text: 'Title / art / genre editable after approval' },
pull: { level: 'good', text: 'Free, self-serve, no fee' },
unpaid: { level: 'good', text: 'Stays up (free tier, 85%)' },
},
note: 'Where the catalog lives today. $0, but a 15% cut and slow (3–4wk) moderation.',
facts: ['$0 forever', '15% rev cut', '3–4wk moderation'],
},
{
id: 'routenote-premium',
name: 'RouteNote · Premium',
short: '100% via renewal',
oneTimeSingle: 0,
oneTimeAlbum: 0,
annualSingle: 12.99,
annualAlbum: 59.99,
flatAnnual: 0,
redeliverCost: 12.99, // remaster = re-deliver, re-pay per release
keepFraction: 1.0,
ownsForever: false,
pullsIfUnpaid: true,
freedom: {
remaster: { level: 'mixed', text: 'Re-upload, re-pay per release' },
edit: { level: 'good', text: 'Editable after approval' },
pull: { level: 'good', text: 'Free, self-serve' },
unpaid: { level: 'mixed', text: 'Likely reverts to free 85% (unverified)' },
},
note: 'Keep 100% — but every release renews yearly. Pricing & lapse behaviour contested (verify).',
facts: ['$12.99/single·yr', '$59.99/album·yr', 'keep 100%'],
},
{
id: 'cdbaby',
name: 'CD Baby',
short: 'recommended',
recommended: true,
oneTimeSingle: 9.99,
oneTimeAlbum: 14.99,
annualSingle: 0,
annualAlbum: 0,
flatAnnual: 0,
redeliverCost: 9.99, // remaster = a brand-new release + new fee, loses date/plays
keepFraction: 0.91,
ownsForever: true,
pullsIfUnpaid: false,
freedom: {
remaster: { level: 'bad', text: 'New release · new fee · loses date & plays' },
edit: { level: 'bad', text: 'Mostly locked after delivery' },
pull: { level: 'good', text: 'Free, self-serve (~30 days)' },
unpaid: { level: 'good', text: 'Stays up forever — no subscription' },
},
note: 'One-time per release, stays up forever, keep 91%. No recurring tax — but releases lock after delivery.',
facts: ['$9.99/single', '$14.99/album', 'keep 91%', 'paid once'],
},
{
id: 'distrokid',
name: 'DistroKid',
short: 'flat, unlimited',
oneTimeSingle: 0,
oneTimeAlbum: 0,
annualSingle: 0,
annualAlbum: 0,
flatAnnual: 22.99,
flatAnnualWithRemaster: 89.99, // Audio Swap (in-place) requires the Ultimate plan
redeliverCost: 0, // in-place swap on Ultimate; tier bump handled in cost()
keepFraction: 1.0,
ownsForever: false,
pullsIfUnpaid: true,
freedom: {
remaster: { level: 'mixed', text: 'In-place swap — Ultimate $90/yr, minor tweaks only' },
edit: { level: 'good', text: 'Editable after upload' },
pull: { level: 'good', text: 'Free, self-serve' },
unpaid: { level: 'bad', text: 'Pulled, unless Leave-a-Legacy ($29/$49 once each)' },
},
note: 'Flat yearly, unlimited releases, keep 100% — but music is pulled if you stop paying.',
facts: ['$22.99/yr flat', 'unlimited', 'keep 100%', 'rented shelf'],
},
]
export interface Release {
kind: Kind
bucket: Bucket
goLiveMonth: number
aged: boolean // already past its release spike (existing catalog)
}
function bundle(tracks: number, perEp: number): { singles: number; albums: number } {
if (perEp <= 1) return { singles: Math.max(0, Math.round(tracks)), albums: 0 }
const albums = Math.floor(tracks / perEp)
const singles = tracks - albums * perEp // leftover tracks ship as singles
return { singles, albums }
}
/** Expand catalog inputs into individual dated releases over the horizon. */
export function buildReleases(c: CatalogInputs): Release[] {
const rs: Release[] = []
for (let i = 0; i < c.existingSingles; i++)
rs.push({ kind: 'single', bucket: 'existing', goLiveMonth: 0, aged: true })
for (let i = 0; i < c.existingAlbums; i++)
rs.push({ kind: 'album', bucket: 'existing', goLiveMonth: 0, aged: true })
// Backlog: re-released to DSP, staggered across the first year (they re-spike).
const back = bundle(c.backlogTracks, c.backlogTracksPerEp)
const backTotal = back.singles + back.albums
let bi = 0
const stagger = (idx: number, n: number) => (n <= 1 ? 0 : Math.floor((idx / n) * 12))
for (let i = 0; i < back.albums; i++, bi++)
rs.push({ kind: 'album', bucket: 'backlog', goLiveMonth: stagger(bi, backTotal), aged: false })
for (let i = 0; i < back.singles; i++, bi++)
rs.push({ kind: 'single', bucket: 'backlog', goLiveMonth: stagger(bi, backTotal), aged: false })
// Forward cadence: per year, split single/album, spread within the year.
for (let y = 0; y < c.horizonYears; y++) {
const perYear = Math.round(c.cadencePerYear)
const albums = Math.round(perYear * c.cadenceAlbumShare)
const singles = perYear - albums
let j = 0
const within = (idx: number) => y * 12 + (perYear <= 1 ? 0 : Math.floor((idx / perYear) * 12))
for (let i = 0; i < albums; i++, j++)
rs.push({ kind: 'album', bucket: 'forward', goLiveMonth: within(j), aged: false })
for (let i = 0; i < singles; i++, j++)
rs.push({ kind: 'single', bucket: 'forward', goLiveMonth: within(j), aged: false })
}
return rs
}
export interface CostBreakdown {
port: number // get current catalog (existing + backlog) onto the platform
keep: number // maintain current catalog over the horizon
add: number // release the forward pipeline
update: number // re-master / re-deliver existing releases
total: number
}
export function cost(
opt: Option,
releases: Release[],
horizonYears: number,
remastersPerYear = 0,
): CostBreakdown {
let port = 0
let keep = 0
let add = 0
for (const r of releases) {
const one = r.kind === 'album' ? opt.oneTimeAlbum : opt.oneTimeSingle
const ann = r.kind === 'album' ? opt.annualAlbum : opt.annualSingle
const liveYears = Math.max(1, horizonYears - Math.floor(r.goLiveMonth / 12))
if (r.bucket === 'forward') {
add += one + ann * liveYears
} else {
port += one + ann // delivery + year-0 renewal
keep += ann * (liveYears - 1) // renewals after year 0
}
}
// Re-mastering an already-live release: a re-delivery (and a new fee) on most
// platforms; in-place on DistroKid but only by paying up to the Ultimate tier.
const update = remastersPerYear * horizonYears * opt.redeliverCost
const flat =
remastersPerYear > 0 && opt.flatAnnualWithRemaster ? opt.flatAnnualWithRemaster : opt.flatAnnual
// Flat platform subscription is the price of keeping the catalog alive at all.
if (flat > 0) keep += flat * horizonYears
return { port, keep, add, update, total: port + keep + add + update }
}
function streamCurve(ageMonths: number, r: RevenueInputs): number {
return r.floorStreams + (r.peakStreams - r.floorStreams) * Math.pow(0.5, ageMonths / r.halfLifeMonths)
}
/** Total DSP streams over the horizon (distributor-independent). */
export function totalStreams(releases: Release[], rev: RevenueInputs, horizonYears: number): number {
const months = horizonYears * 12
let streams = 0
for (let m = 0; m < months; m++) {
for (const r of releases) {
if (m < r.goLiveMonth) continue
const base = r.aged ? rev.floorStreams : streamCurve(m - r.goLiveMonth, rev)
streams += base * (r.kind === 'album' ? ALBUM_STREAM_MULT : 1)
}
}
return streams
}
/** Bandcamp is an independent direct-to-fan layer: same revenue whatever the
* distributor, so it shifts every option equally and never decides the comparison.
* Modelled as flat catalog-wide digital sales per year (a few, realistically). */
export function bandcampRevenue(rev: RevenueInputs, horizonYears: number): number {
if (!rev.bcLayer) return 0
return rev.bcSalesPerYear * horizonYears * rev.bcPrice * BC_KEEP
}
export interface OptionResult {
opt: Option
cost: CostBreakdown
streamNet: number // streaming revenue kept after the distributor's cut
bcRevenue: number // shared Bandcamp layer (identical across options)
profit: number // streamNet + bcRevenue − distributor cost
vsBaseline: number // profit delta against RouteNote-free
}
export function simulate(
cat: CatalogInputs,
rev: RevenueInputs,
): {
releases: Release[]
results: OptionResult[]
counts: ReturnType<typeof catalogCounts>
streams: number
bcRevenue: number
} {
const releases = buildReleases(cat)
const streams = totalStreams(releases, rev, cat.horizonYears)
const streamGross = streams * rev.perStream
const bcRevenue = bandcampRevenue(rev, cat.horizonYears)
const raw = OPTIONS.map((opt) => {
const c = cost(opt, releases, cat.horizonYears, cat.remastersPerYear)
const streamNet = streamGross * opt.keepFraction
return { opt, cost: c, streamNet, bcRevenue, profit: streamNet + bcRevenue - c.total }
})
const baseline = raw.find((x) => x.opt.baseline)?.profit ?? 0
const results = raw.map((x) => ({ ...x, vsBaseline: x.profit - baseline }))
return { releases, results, counts: catalogCounts(releases), streams, bcRevenue }
}
export function catalogCounts(releases: Release[]) {
const by = (b: Bucket) => releases.filter((r) => r.bucket === b)
return {
total: releases.length,
existing: by('existing').length,
backlog: by('backlog').length,
forward: by('forward').length,
singles: releases.filter((r) => r.kind === 'single').length,
albums: releases.filter((r) => r.kind === 'album').length,
}
}
// ── Break-even: lifetime cost as a function of forward cadence ──────────────
export interface BreakEvenPoint {
cadence: number
costs: Record<string, number>
}
export function breakEvenSweep(cat: CatalogInputs, maxCadence = 16): BreakEvenPoint[] {
const pts: BreakEvenPoint[] = []
for (let cad = 0; cad <= maxCadence; cad++) {
const releases = buildReleases({ ...cat, cadencePerYear: cad })
const costs: Record<string, number> = {}
for (const opt of OPTIONS)
costs[opt.id] = cost(opt, releases, cat.horizonYears, cat.remastersPerYear).total
pts.push({ cadence: cad, costs })
}
return pts
}
/** First cadence (releases/yr) at which DistroKid becomes cheaper than CD Baby. */
export function distrokidCrossover(cat: CatalogInputs): number | null {
const cdbaby = OPTIONS.find((o) => o.id === 'cdbaby')!
const dk = OPTIONS.find((o) => o.id === 'distrokid')!
for (let cad = 0; cad <= 40; cad++) {
const releases = buildReleases({ ...cat, cadencePerYear: cad })
const c = cost(cdbaby, releases, cat.horizonYears).total
const d = cost(dk, releases, cat.horizonYears).total
if (d < c) return cad
}
return null
}
......@@ -228,8 +228,7 @@
"clave",
"shaker",
"tabla",
"cowbell",
"drum"
"cowbell"
]
},
{
......@@ -278,10 +277,6 @@
"match": [
"break",
"amen",
"loop",
"jungle",
"dnb",
"jazz",
"breaks165",
"fbreak"
]
......
......@@ -48,4 +48,13 @@ export default defineConfig({
plugins: [react(), tailwindcss(), audioDevServer()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
server: { host: true }, // expose on LAN for phone audition (same wifi)
build: {
rollupOptions: {
input: {
// Multi-page: the take-judge (index) + the distribution simulator (sextant).
main: path.resolve(__dirname, 'index.html'),
sextant: path.resolve(__dirname, 'sextant.html'),
},
},
},
})
......@@ -67,6 +67,10 @@ Instructions
# Work in progress⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
## 26 lettres de noblesses
-- Vague de Crime
## Juin Faites de la musique
-- Vague de Crime
## Mai fait ce qu'il te plait
-- Do it Right
-- Bois BUMBUM
......@@ -1885,6 +1889,45 @@ ParVagues et Shipow
-- Silence --
[101] 5mn **Outro: Sweet Revolution**
# Zorg Non-anniv
-- Do it Right
-- Liquid Finale
-- Something About US
-- You My Sunshine
-- Jeudrill
## PAUSE PAS DE SAMPLES
-- L'Tte A Mauerpark
-- L'Insouciance
## FINISH BACH
-- Chemin de traverse
## Jardin des Flutes Deviantes
-- *Quand on decolle*
-- *l'Ete a Mauerpark*
-- Chemin de traverse!
-- Premier Septembre
## OPAL 2026
## Livecoding Techno DNB Nu-jazz
-- Piment Bresilien
-- WAP
-- Perfect <3
-- Premier Septembre
-- Gimme Acid??!?
-- Vague de CRIME
-- Mafia
-- Sunshine
-- Desire
-- Something about drums
## Images
......
......@@ -3,7 +3,7 @@
-- À Mon Amour
-- <3
do
-- resetCycles
resetCycles
setcps (120/60/4)
let gMask = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>"))
let gMute1 = (midiOn "^73" (mask "f*16"))
......
do
resetCycles
-- resetCycles
setcps (129/60/4)
d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf with flourish (NTO: deepens over time)
$ midiOn "^42" (<| "k k k <k k*2 k [~ k]>") -- ON: 4otf + flourish variations
......
......@@ -15,7 +15,7 @@ d1 $ gF1 $ gM2
-- $ "kick:5"
# gain 1.7
# lpf 400
d2 $ gF1 $ gM1
d2 $ gF1 $ gM1 -- Clap Technocratique
$ midiOn "^43" (mask "[t <f t!3>] [t <f t>]" . fast 2)
$ "~ s ~ [s*<1 2> <~ s*<2 [4 2]>>]"
# "vec1_claps" # n 10
......
......@@ -67,15 +67,17 @@ d5 $ gF3 $ gM3
- 12
+ "[0,12]"
)
# "FMRhodes1"
-- # "FMRhodes1"
-- # "giorgio_syn:20" |+ note (12 - 2)
# "acidOto3092"
-- lagTime = 0.12, filterRange = 6, width = 0.51, rq = 0.3;
-- 18 │
# width (range 0.02 0.8 "^54") -- /!\ FIXME EXPLOSIF /!\
# width (range 0.2 0.8 "^54") -- /!\ FIXME EXPLOSIF /!\
# filterRange (range 0 8 "^34")
# filterRange (range 0 8 "^34")
# room 0.4 # dry 0.8
# gain 1.2
# gain 1.3
# octer 0.4
# att 0.01 # rel 10
-- # pan "[0.4|0.6]*8"
d8 $ gF1 $ gM1
......@@ -105,3 +107,6 @@ d11 $ gF3 $ gM3 -- GIMME THAT
# "vocal_bordel"
# cut 11
# gain 1.3 # lpf 3000 # room 0.3 # octersub 0.8
d12 $ gM3 $ gM3 -- CHEVAL DE COURSE
$ "<~ cheval>" # "molotov:5"
# room 0.4 # sz 0.3 # dry 0.3
......@@ -24,14 +24,14 @@ d2 $ gF1 $ gM1
$ midiOff "^43" (<| "~ . c*<1!3 <2!3 4>> ~")
$ "rampleS37:5"
# gain 1.8
# lpf 2650
-- # lpf 2650
d3 $ gF1 $ gM1 -- Highest hats
$ midiOn "^44" (ply 2)
$ midiOn "^76" (ply 2)
$ sometimesBy "0!3 <0 0.5>" (# n 12)
$ sometimesBy "0!3 <0.1 0>" (# n 13)
$ "~ d ~ d d d d*<1 1 2 1> d"
# "rampleS34:6"
# "[rampleS34:6,clap]"
# cut 3
# legato (range 0.28 1 sine)
# gain (1.1 * (range 0.85 1.05 (fast 4 perlin)))
......
......@@ -7,7 +7,7 @@ d1 $ gF1 $ gM2 -- Le Bum dans Bumbum
$ midiOn "^41" (mask "<t f> <f!3 [f t]>")
$ midiOn "^42" (<| "k k k k")
$ midiOff "^42" (<| "k . k(<3!7 8>,<9 8 8 9>)")
$ "[rampleK2,jazz]"
$ "[jazz]"
# gain 1.3
d2 $ gM1 $ gF1 -- Popping snare
$ "~ s ~ <s <s*2 [s s s ~]>>" -- TODO Rewrite ?
......@@ -21,6 +21,7 @@ d3 $ gM1 $ gF1 -- TAMTAM
$ "rampleS20:3"
# gain 1.8
d4 $ gF2 $ gM3
$ midiOn "^89" (ply "4 8")
$ midiOn "^57" (superimpose
(|+| note (struct "t*8" $ arp "up" ("c'maj'4 c'maj'4")
-- + 12
......@@ -58,7 +59,7 @@ d7 $ gF3 $ gMute3 -- When the voice says BumBum
$ n (slow 2 $ "<10 11 12 13 14 15 16 17 13 13 ~ ~ 15 16 15 16 17 17 17 17 18 18 17 17 18 19 20 20 20 19 21 22 23 24>") -- La Complete
# "bumbum"
# cut 7
# gain 1.3
# gain 1.1
d8 $ gF1 $ gM1 -- Jungle breaks stack
$ midiOff "^60" (mask "t(8,16,1)" . chop 8)
$ loopAt 4
......
......@@ -3,6 +3,7 @@ once $ "gfunk_lead"
do
setcps (120/60/4)
-- resetCycles
let width = pF "width"
let gMask = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>"))
let gMute = (midiOn "^73" (mask "f*16"))
......
......@@ -20,13 +20,12 @@ d1 $ gF1 $ gMute2
$ "[jazz,clubkick]"
# cut 1
# gain 1.5
d2 $ gF2 $ gM1
$ midiOn "^43" (<| "~ s ~ [s*<1 2> <~!7 [~ s]>]")
$ midiOff "^43" (<| "~ s")
$ "snare:45"
# "h2ogmcp"
# gain 1.5
# room 0.08 # sz 0.3
d2 $ gF1 $ gM1 -- Clap Technocratique
$ midiOn "^43" (mask "[t <f t!3>] [t <f t>]" . fast 2)
$ "~ s ~ [s*<1 2> <~ s*<2 [4 2]>>]"
# "vec1_claps" # n 10
# gain 1.7
# lpf 4000 # cut 2
d3 $ gF1 $ gM1
$ midiOn "^44" (ply "2 <2!3 [2|4|[4 8]]>")
$ "~ h ~ h ~ h*<1!3 2> ~ h*<1 [1|2]>"
......
......@@ -7,7 +7,7 @@ d1 $ fast 2
$ midiOn "^30" (# "jazz:0")
$ midiOff "^42" (<| "k . ~ k ~ ~")
$ midiOn "^42" (<| "k k . k <k [~ k] k k*2>")
$ "popkick:2"
$ "[popkick:2,kick:5]"
# lpf 300 -- TODO Sound design this kick <3
-- # cut 1
# gain 2
......@@ -15,19 +15,16 @@ d1 $ fast 2
d2 $ fast 2 $ gF1 $ gM1
$ midiOn "^43" (<| "~ s ~ s*<1!3 <2 [4 2]>>")
$ midiOff "^43" (<| "~ . s*<1!3 2> ~")
$ "[realclaps:0]"
$ "[realclaps:0,snare:56]"
-- # "h2ogmcp"
-- # gain (1.0 * "<[1]!16 [1 <1 <1 [1 0.93] 1 [0.9]>>]!16>")
# gain 1.2
-- # room 0.5 # dry 1.1
-- # delay "<0!15 0.6!1>"
-- # delayt 0.25
d3 $ gM1 $ gF1
$ fast "<1!8 2!6 4>"
$ "~ d ~ d ~ d ~ <d!12 [~ d]!3 [d d]>"
# "snare:34"
$ "~ d ~ d ~ d ~ d ~ d*<1 2 2 1> ~ d ~ d ~ d"
# "h2ogmhh"
# hpf 7000
# gain 1.4
# room 0.3 # sz 0.5 # dry 0.9
d4 $ gF2 $ gM3
$ note "[<as2 cs3 f3 cs3> <b2 ef3 fs3 b2>@7]"
# "bassWarsaw" # cut 4
......@@ -61,9 +58,10 @@ d8 $ gF1
$ midiOn "^92" (ply "<2 2 2 <4 8>>")
$ midiOff "^60" (mask "t(8,16,1)" . chop 16)
$ chop 8
$ fix (loopAt 2) (n 155)
$ loopAt 1
$ midiOn "^36" (loopAt 0.5 . (# n 135))
$ midiOn "^56" (# n 55)
$ midiOn "^36" (loopAt "0.5" . (# n "<135!3 [<135 155> 155]>"))
$ midiOn "^56" (# n 152)
$ "jungle_breaks:74"
# cut 8
d10
......
resetCycles
do
setcps (120/60/4)
d1 $ gMute2 $ gF1 -- Kick solide
$ fix ((# att 0.02) . (# rel 0.5) . (# lpf 5000)) "kick:4"
$ midiOn "^42" (struct "t t t t*<1!6 2 2>")
$ midiOff "^42" (<| "k . ~ <~!3 k> ~ ~")
$ "[kick:4,jazz]"
# gain 1.2
d2 $ gF1 $ gM1 $ "~ c . <[~ c ~ c] [<c ~ ~ c> <~ c ~ c> . ~]>"
# "cp" # gain 1.1
# lpf 2000
# room 0.4 # sz 0.6 # dry 1.04 # octersub 0.8
d3 $ gF1 $ gM1
$ midiOn "^44" (>| "[~ hh]*4")
$ midiOff "^44" (chop 8 . loopAt 4 . (>| "crimewave:3") . ("e" ~>))
$ "drums"
# cut 3 # gain 1.6
# pan (range 0.4 0.8 perlin)
d4 $ gF2 $ gM3 $ chop 4 -- CRIMINAL BASSLINE
$ midiOn "^89" (ply "<[2 <2 [4 2]>]!3 [2 4]>")
$ slice 4 "<0 1 2 3>"
-- $ chop 4
$ "crimewave"
# n "<5!4 6!4>" # cut 4
# lpf 2000 # octersub 0.8
# gain 1.4
# crushbus 41 (range 7 3.13 "^53")
# octersubbus 42 (range 0 2.13 "^33")
# room 0.3 # sz 0.3 # dry 1.13
d5 $ gM3 $ gF3 -- THIS SYNTH IS A CRIME <3
$ slice 16 (slow 4 $ run 16)
$ midiOn "^58" (-- LZRTAG TAKEOVER <3 <3
slice 8 (run 4) . (>| n (slow 4 "<11 12 13 14>"))
)
-- $ midiOn "^90" (ply 4) . (# begin 0.25)
$ midiOff "^58" (>| n (slow 2 $ "<0 1 2 <~ >>"))
$ "crimewave" # cut 5
d7 $ gM3 $ gF3 -- DARK LIPS <3
$ midiOn "^91" (ply "4 <4!3 8>")
$ midiOn "^35" ( -- GLITCH VOICE
(>| n (slow 2 "<16 17 18 19 20 21 22 23>"))
. (# room 0.3) . (# dry 0.9) . (# sz 0.4) )
$ slice 4 ("<0 1 2 3>") -- Structure par 4
$ midiOff "^59" (>| n (slow 8 "<9 10 7 8>" )) -- Loop Intro to Rough
$ midiOn "^59"
-- REMIX VOCALS CRIME
(
(>| n (slow 4 "<16 17 16 17 18 19 18 19 .8. 20 21 20 21 22 22 22 23>"))
. (|* gain 0.8)
. (# room 0.4) . (# dry 0.9)
)
$ "crimewave" # cut 7
# gain 1.45
d8 $ gF1 $ gM1 $ chop 8 $ loopAt 2
$ "jungle_breaks:74"
# cut 8
d10 $ gF1 $ gM1 $ mask "<t f!7>" $ "risers:8"
d6 $ gF3 $ gM3 $ slice 4 "<0 1 2 <3 3*2>>"
$ "crimewave:15"
# cut 9
# room 0.3 # sz 0.4 # dry 0.9
# delay 0.2 # delayfb 0.8 # delayt 0.125
# octersub 0.6
# gain 1.3
# lpf 4000
d6 $ "crimewave" # cut 6
once $ "crimewave:23" # cut 6
# gain 1.3
# octersub 0.6 # room 0.3 # sz 0.3 # dry 0.8
# pan 0.4
......@@ -4,6 +4,13 @@
# Set variables
SAVED_CPU_GOVERNOR="/tmp/saved_cpu_governor.txt"
SAVED_EPP="/tmp/saved_cpu_epp.txt"
SAVED_MAX_PCT="/tmp/saved_cpu_max_pct.txt"
# Peak-clock cap for --cool (percent of max). 85% still shaves the sustained
# turbo spikes that drive thermal-throttle events, but leaves enough ceiling for
# the editor's bursty UI work to clear quickly. Tune with COOL_MAX_PCT=NN
# (lower = cooler but laggier UI; higher = snappier but warmer).
COOL_MAX_PCT="${COOL_MAX_PCT:-85}"
# Display help
show_help() {
......@@ -14,6 +21,8 @@ show_help() {
echo "Options:"
echo " --optimize Apply standard performance optimizations to running processes"
echo " --extreme Apply extreme performance optimizations (for live performance)"
echo " --cool Thermal-aware mode: fix audio RT priorities + cap peak clock"
echo " (heatwave / fanless-feel; keeps audio snappy without cooking the CPU)"
echo " --stop Reset system to normal operation"
echo " --check Check current system load and running processes"
echo " --diagnose Run diagnostics to investigate audio stutters"
......@@ -319,6 +328,73 @@ optimize_extreme() {
echo "To reset when done, run: sudo $0 --stop"
}
# Function to apply THERMAL-AWARE optimizations (heatwave mode)
# Philosophy is the OPPOSITE of --extreme: instead of pinning the CPU to 100%
# (max heat), we cap the peak clock to shave turbo spikes, and lean on proper
# real-time scheduling so the audio chain stays glitch-free at a lower, cooler
# clock. Fixing scsynth's RT priority is what makes the cap free of audio cost.
optimize_cool() {
check_root "--cool"
echo "========================================"
echo "APPLYING THERMAL-AWARE (COOL) OPTIMIZATIONS"
echo "========================================"
# Save current state for clean restore
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor > $SAVED_CPU_GOVERNOR
cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference > $SAVED_EPP 2>/dev/null
cat /sys/devices/system/cpu/intel_pstate/max_perf_pct > $SAVED_MAX_PCT 2>/dev/null
# Stop the few indexers/updaters that wake the CPU in the background
echo "Quieting background CPU wakers..."
USER=$(logname || whoami)
systemctl --user -M $USER@ stop kde-baloo.service 2>/dev/null
systemctl --user -M $USER@ stop plasma-baloorunner.service 2>/dev/null
systemctl stop packagekit.service 2>/dev/null
echo "✓ Background services quieted"
# Keep the powersave governor — it idles cool and ramps on demand.
echo "Setting CPU governor to powersave (cool idle, on-demand ramp)..."
echo powersave | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor > /dev/null
echo "✓ Governor: powersave"
# Bias the energy/perf hint to balance_performance: intel_pstate still idles
# cool, but ramps FAST when there's demand. balance_power was too lazy here —
# it left the cores at ~2.4GHz even while Pulsar's renderer screamed at 118%,
# which is exactly the few-hundred-ms UI freezes the editor was showing. This
# keeps the heat win (idle backoff + the cap below) without starving bursts.
if [ -f /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference ]; then
echo balance_performance | tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference > /dev/null 2>&1
echo "✓ EPP: balance_performance (fast ramp, cool idle)"
fi
# Cap the PEAK clock instead of pinning it. This is the core heat lever.
if [ -d /sys/devices/system/cpu/intel_pstate ]; then
echo "$COOL_MAX_PCT" > /sys/devices/system/cpu/intel_pstate/max_perf_pct 2>/dev/null
# Leave min low so it still drops to a cool idle between events.
echo 15 > /sys/devices/system/cpu/intel_pstate/min_perf_pct 2>/dev/null
# Turbo stays ON but bounded by the cap above — brief headroom, no sustained roast.
echo 0 > /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null
echo "✓ Peak clock capped to ${COOL_MAX_PCT}% (turbo bounded, idle stays low)"
fi
# Light VM tuning: avoid swap-driven stalls during a set without forcing anything.
echo 10 > /proc/sys/vm/swappiness 2>/dev/null
echo "✓ Swappiness lowered to 10"
# The actual lag fix lives here: give the audio chain real RT priority so it
# stays responsive even at the capped clock. scsynth at FIFO 1 -> 90.
set_priorities cool
echo ""
echo "✅ Cool mode applied."
echo " • Audio chain now real-time scheduled (scsynth/sclang/pipewire)."
echo " • Peak clock capped at ${COOL_MAX_PCT}% to keep temps down."
echo " • Editor/desktop niced so they don't steal cycles from the audio."
echo " Re-run with COOL_MAX_PCT=70 ... for more cooling, 90 for more headroom."
echo " Reset anytime: sudo $0 --stop"
}
# Function to set process priorities
set_priorities() {
mode=$1
......@@ -395,7 +471,7 @@ set_priorities() {
# Find OBS Studio if running
OBS_PID=$(pgrep obs)
if [ ! -z "$OBS_PID" ]; then
if [ "$mode" = "extreme" ]; then
if [ "$mode" != "standard" ]; then
renice -n 5 -p $OBS_PID
ionice -c 2 -n 5 -p $OBS_PID
else
......@@ -449,8 +525,8 @@ set_priorities() {
fi
if [ ! -z "$PULSAR_PIDS" ]; then
if [ "$mode" = "extreme" ]; then
# Extreme: low but NOT idle — keeps editor usable while livecoding
if [ "$mode" != "standard" ]; then
# Extreme/cool: low but NOT idle — keeps editor usable while livecoding
for PID in $PULSAR_PIDS; do
renice -n 10 -p $PID >/dev/null 2>&1
ionice -c 2 -n 6 -p $PID >/dev/null 2>&1
......@@ -472,7 +548,7 @@ set_priorities() {
# KDE Window Manager (X11 or Wayland)
KWIN_PID=$(pgrep -x kwin_x11 || pgrep -x kwin_wayland)
if [ ! -z "$KWIN_PID" ]; then
if [ "$mode" = "extreme" ]; then
if [ "$mode" != "standard" ]; then
renice -n 10 -p $KWIN_PID >/dev/null 2>&1
ionice -c 2 -n 6 -p $KWIN_PID >/dev/null 2>&1
echo "✓ Set KWin (PID $KWIN_PID) to nice 10 (low but not starved)"
......@@ -485,7 +561,7 @@ set_priorities() {
# Plasma Shell
PLASMA_PID=$(pgrep -x plasmashell)
if [ ! -z "$PLASMA_PID" ]; then
if [ "$mode" = "extreme" ]; then
if [ "$mode" != "standard" ]; then
renice -n 15 -p $PLASMA_PID >/dev/null 2>&1
ionice -c 2 -n 7 -p $PLASMA_PID >/dev/null 2>&1
echo "✓ Set Plasma Shell (PID $PLASMA_PID) to nice 15"
......@@ -546,7 +622,21 @@ reset_system() {
if [ -d "/sys/devices/system/cpu/intel_pstate" ]; then
echo 0 > /sys/devices/system/cpu/intel_pstate/min_perf_pct 2>/dev/null
echo "✓ Intel CPU min performance reset"
# Restore the peak-clock cap (--cool lowers it; --extreme leaves it at 100)
if [ -f "$SAVED_MAX_PCT" ]; then
cat "$SAVED_MAX_PCT" > /sys/devices/system/cpu/intel_pstate/max_perf_pct 2>/dev/null
rm -f "$SAVED_MAX_PCT"
else
echo 100 > /sys/devices/system/cpu/intel_pstate/max_perf_pct 2>/dev/null
fi
echo "✓ Intel CPU perf caps reset"
fi
# Restore EPP (energy/perf hint) if --cool changed it
if [ -f "$SAVED_EPP" ] && [ -f /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference ]; then
cat "$SAVED_EPP" | tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference > /dev/null 2>&1
rm -f "$SAVED_EPP"
echo "✓ EPP restored"
fi
# Restore NMI watchdog
......@@ -648,6 +738,9 @@ case "$1" in
--extreme)
optimize_extreme
;;
--cool)
optimize_cool
;;
--stop)
reset_system
;;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment