Commit 5f3f4d21 by PLN (Algolia)

update :D

parent f627f6ca
...@@ -10,6 +10,7 @@ ParVagues' main livecoding workspace. ...@@ -10,6 +10,7 @@ ParVagues' main livecoding workspace.
- `backlog.md` — track backlog, setlists, gig prep notes - `backlog.md` — track backlog, setlists, gig prep notes
- `perf.sh` — performance-mode optimizer (CPU governor, etc.) - `perf.sh` — performance-mode optimizer (CPU governor, etc.)
- `test.tidal` — scratchpad - `test.tidal` — scratchpad
- `armada/` — media/marketing toolbox (catalog, distribution, diffusion, gigs); see `armada/README.md`
## Sister projects ## Sister projects
...@@ -22,10 +23,41 @@ ParVagues' main livecoding workspace. ...@@ -22,10 +23,41 @@ ParVagues' main livecoding workspace.
## Conventions ## 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 - 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) - Stems exported from Ardour as `Montreuil26_Tidal 01.wav` style (one per orbit)
- Visuals composited in post; OBS captures clean code only - Visuals composited in post; OBS captures clean code only
- Rhadamanthe's master-chain notes: `live/collab/rhadamanthe/layering.tidal` - 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 ## See also
......
...@@ -36,6 +36,15 @@ you self-produce). Watch **CNM** *musiques actuelles* mobility/export aid for to ...@@ -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 → 7. Reassess in 12 mo: ~50k monthly listeners → apply **AWAL**; JP traction →
**TuneCore Japan** + bilingual metadata. **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 ## Next
Feeds off `tide-table` gap analysis → drives the **release manifeste** (task #8): 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)} ...@@ -153,6 +153,33 @@ footer .sig span{color:var(--magenta)}
background:#ff52520f;color:var(--ink);font-size:14px;line-height:1.7} background:#ff52520f;color:var(--ink);font-size:14px;line-height:1.7}
.banner b{color:#ff7a7a} .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 */ /* tooltip — the "what am I looking at" layer */
.tip{position:fixed;z-index:60;pointer-events:none;opacity:0; .tip{position:fixed;z-index:60;pointer-events:none;opacity:0;
transition:opacity .12s ease;max-width:280px; transition:opacity .12s ease;max-width:280px;
...@@ -242,6 +269,36 @@ function onMove(e){ ...@@ -242,6 +269,36 @@ function onMove(e){
} }
document.addEventListener("mousemove",onMove); 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) ──────── */ /* ── data load: inlined block first (deploy), else fetch (dev/serve) ──────── */
function embedded(id){const e=document.getElementById(id); function embedded(id){const e=document.getElementById(id);
if(e&&e.textContent.trim()){try{return JSON.parse(e.textContent)}catch(_){}}return null;} 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 @@ ...@@ -50,3 +50,44 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } * { 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 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)}`
}
...@@ -228,8 +228,7 @@ ...@@ -228,8 +228,7 @@
"clave", "clave",
"shaker", "shaker",
"tabla", "tabla",
"cowbell", "cowbell"
"drum"
] ]
}, },
{ {
...@@ -278,10 +277,6 @@ ...@@ -278,10 +277,6 @@
"match": [ "match": [
"break", "break",
"amen", "amen",
"loop",
"jungle",
"dnb",
"jazz",
"breaks165", "breaks165",
"fbreak" "fbreak"
] ]
......
...@@ -48,4 +48,13 @@ export default defineConfig({ ...@@ -48,4 +48,13 @@ export default defineConfig({
plugins: [react(), tailwindcss(), audioDevServer()], plugins: [react(), tailwindcss(), audioDevServer()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } }, resolve: { alias: { '@': path.resolve(__dirname, './src') } },
server: { host: true }, // expose on LAN for phone audition (same wifi) 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 ...@@ -67,6 +67,10 @@ Instructions
# Work in progress⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ # Work in progress⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
## 26 lettres de noblesses ## 26 lettres de noblesses
-- Vague de Crime
## Juin Faites de la musique
-- Vague de Crime
## Mai fait ce qu'il te plait ## Mai fait ce qu'il te plait
-- Do it Right -- Do it Right
-- Bois BUMBUM -- Bois BUMBUM
...@@ -1885,6 +1889,45 @@ ParVagues et Shipow ...@@ -1885,6 +1889,45 @@ ParVagues et Shipow
-- Silence -- -- Silence --
[101] 5mn **Outro: Sweet Revolution** [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 ## Images
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
-- À Mon Amour -- À Mon Amour
-- <3 -- <3
do do
-- resetCycles resetCycles
setcps (120/60/4) setcps (120/60/4)
let gMask = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>")) let gMask = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>"))
let gMute1 = (midiOn "^73" (mask "f*16")) let gMute1 = (midiOn "^73" (mask "f*16"))
......
do do
resetCycles -- resetCycles
setcps (129/60/4) setcps (129/60/4)
d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf with flourish (NTO: deepens over time) 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 $ midiOn "^42" (<| "k k k <k k*2 k [~ k]>") -- ON: 4otf + flourish variations
......
...@@ -15,7 +15,7 @@ d1 $ gF1 $ gM2 ...@@ -15,7 +15,7 @@ d1 $ gF1 $ gM2
-- $ "kick:5" -- $ "kick:5"
# gain 1.7 # gain 1.7
# lpf 400 # lpf 400
d2 $ gF1 $ gM1 d2 $ gF1 $ gM1 -- Clap Technocratique
$ midiOn "^43" (mask "[t <f t!3>] [t <f t>]" . fast 2) $ midiOn "^43" (mask "[t <f t!3>] [t <f t>]" . fast 2)
$ "~ s ~ [s*<1 2> <~ s*<2 [4 2]>>]" $ "~ s ~ [s*<1 2> <~ s*<2 [4 2]>>]"
# "vec1_claps" # n 10 # "vec1_claps" # n 10
......
...@@ -67,15 +67,17 @@ d5 $ gF3 $ gM3 ...@@ -67,15 +67,17 @@ d5 $ gF3 $ gM3
- 12 - 12
+ "[0,12]" + "[0,12]"
) )
# "FMRhodes1" -- # "FMRhodes1"
-- # "giorgio_syn:20" |+ note (12 - 2) -- # "giorgio_syn:20" |+ note (12 - 2)
# "acidOto3092" # "acidOto3092"
-- lagTime = 0.12, filterRange = 6, width = 0.51, rq = 0.3; -- lagTime = 0.12, filterRange = 6, width = 0.51, rq = 0.3;
-- 18 │ -- 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") # filterRange (range 0 8 "^34")
# room 0.4 # dry 0.8 # room 0.4 # dry 0.8
# gain 1.2 # gain 1.3
# octer 0.4
# att 0.01 # rel 10 # att 0.01 # rel 10
-- # pan "[0.4|0.6]*8" -- # pan "[0.4|0.6]*8"
d8 $ gF1 $ gM1 d8 $ gF1 $ gM1
...@@ -105,3 +107,6 @@ d11 $ gF3 $ gM3 -- GIMME THAT ...@@ -105,3 +107,6 @@ d11 $ gF3 $ gM3 -- GIMME THAT
# "vocal_bordel" # "vocal_bordel"
# cut 11 # cut 11
# gain 1.3 # lpf 3000 # room 0.3 # octersub 0.8 # 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 ...@@ -24,14 +24,14 @@ d2 $ gF1 $ gM1
$ midiOff "^43" (<| "~ . c*<1!3 <2!3 4>> ~") $ midiOff "^43" (<| "~ . c*<1!3 <2!3 4>> ~")
$ "rampleS37:5" $ "rampleS37:5"
# gain 1.8 # gain 1.8
# lpf 2650 -- # lpf 2650
d3 $ gF1 $ gM1 -- Highest hats d3 $ gF1 $ gM1 -- Highest hats
$ midiOn "^44" (ply 2) $ midiOn "^44" (ply 2)
$ midiOn "^76" (ply 2) $ midiOn "^76" (ply 2)
$ sometimesBy "0!3 <0 0.5>" (# n 12) $ sometimesBy "0!3 <0 0.5>" (# n 12)
$ sometimesBy "0!3 <0.1 0>" (# n 13) $ sometimesBy "0!3 <0.1 0>" (# n 13)
$ "~ d ~ d d d d*<1 1 2 1> d" $ "~ d ~ d d d d*<1 1 2 1> d"
# "rampleS34:6" # "[rampleS34:6,clap]"
# cut 3 # cut 3
# legato (range 0.28 1 sine) # legato (range 0.28 1 sine)
# gain (1.1 * (range 0.85 1.05 (fast 4 perlin))) # gain (1.1 * (range 0.85 1.05 (fast 4 perlin)))
......
...@@ -7,7 +7,7 @@ d1 $ gF1 $ gM2 -- Le Bum dans Bumbum ...@@ -7,7 +7,7 @@ d1 $ gF1 $ gM2 -- Le Bum dans Bumbum
$ midiOn "^41" (mask "<t f> <f!3 [f t]>") $ midiOn "^41" (mask "<t f> <f!3 [f t]>")
$ midiOn "^42" (<| "k k k k") $ midiOn "^42" (<| "k k k k")
$ midiOff "^42" (<| "k . k(<3!7 8>,<9 8 8 9>)") $ midiOff "^42" (<| "k . k(<3!7 8>,<9 8 8 9>)")
$ "[rampleK2,jazz]" $ "[jazz]"
# gain 1.3 # gain 1.3
d2 $ gM1 $ gF1 -- Popping snare d2 $ gM1 $ gF1 -- Popping snare
$ "~ s ~ <s <s*2 [s s s ~]>>" -- TODO Rewrite ? $ "~ s ~ <s <s*2 [s s s ~]>>" -- TODO Rewrite ?
...@@ -21,6 +21,7 @@ d3 $ gM1 $ gF1 -- TAMTAM ...@@ -21,6 +21,7 @@ d3 $ gM1 $ gF1 -- TAMTAM
$ "rampleS20:3" $ "rampleS20:3"
# gain 1.8 # gain 1.8
d4 $ gF2 $ gM3 d4 $ gF2 $ gM3
$ midiOn "^89" (ply "4 8")
$ midiOn "^57" (superimpose $ midiOn "^57" (superimpose
(|+| note (struct "t*8" $ arp "up" ("c'maj'4 c'maj'4") (|+| note (struct "t*8" $ arp "up" ("c'maj'4 c'maj'4")
-- + 12 -- + 12
...@@ -58,7 +59,7 @@ d7 $ gF3 $ gMute3 -- When the voice says BumBum ...@@ -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 $ 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" # "bumbum"
# cut 7 # cut 7
# gain 1.3 # gain 1.1
d8 $ gF1 $ gM1 -- Jungle breaks stack d8 $ gF1 $ gM1 -- Jungle breaks stack
$ midiOff "^60" (mask "t(8,16,1)" . chop 8) $ midiOff "^60" (mask "t(8,16,1)" . chop 8)
$ loopAt 4 $ loopAt 4
......
...@@ -3,6 +3,7 @@ once $ "gfunk_lead" ...@@ -3,6 +3,7 @@ once $ "gfunk_lead"
do do
setcps (120/60/4) setcps (120/60/4)
-- resetCycles -- resetCycles
let width = pF "width" let width = pF "width"
let gMask = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>")) let gMask = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>"))
let gMute = (midiOn "^73" (mask "f*16")) let gMute = (midiOn "^73" (mask "f*16"))
......
...@@ -20,13 +20,12 @@ d1 $ gF1 $ gMute2 ...@@ -20,13 +20,12 @@ d1 $ gF1 $ gMute2
$ "[jazz,clubkick]" $ "[jazz,clubkick]"
# cut 1 # cut 1
# gain 1.5 # gain 1.5
d2 $ gF2 $ gM1 d2 $ gF1 $ gM1 -- Clap Technocratique
$ midiOn "^43" (<| "~ s ~ [s*<1 2> <~!7 [~ s]>]") $ midiOn "^43" (mask "[t <f t!3>] [t <f t>]" . fast 2)
$ midiOff "^43" (<| "~ s") $ "~ s ~ [s*<1 2> <~ s*<2 [4 2]>>]"
$ "snare:45" # "vec1_claps" # n 10
# "h2ogmcp" # gain 1.7
# gain 1.5 # lpf 4000 # cut 2
# room 0.08 # sz 0.3
d3 $ gF1 $ gM1 d3 $ gF1 $ gM1
$ midiOn "^44" (ply "2 <2!3 [2|4|[4 8]]>") $ midiOn "^44" (ply "2 <2!3 [2|4|[4 8]]>")
$ "~ h ~ h ~ h*<1!3 2> ~ h*<1 [1|2]>" $ "~ h ~ h ~ h*<1!3 2> ~ h*<1 [1|2]>"
......
...@@ -7,7 +7,7 @@ d1 $ fast 2 ...@@ -7,7 +7,7 @@ d1 $ fast 2
$ midiOn "^30" (# "jazz:0") $ midiOn "^30" (# "jazz:0")
$ midiOff "^42" (<| "k . ~ k ~ ~") $ midiOff "^42" (<| "k . ~ k ~ ~")
$ midiOn "^42" (<| "k k . k <k [~ k] k k*2>") $ midiOn "^42" (<| "k k . k <k [~ k] k k*2>")
$ "popkick:2" $ "[popkick:2,kick:5]"
# lpf 300 -- TODO Sound design this kick <3 # lpf 300 -- TODO Sound design this kick <3
-- # cut 1 -- # cut 1
# gain 2 # gain 2
...@@ -15,19 +15,16 @@ d1 $ fast 2 ...@@ -15,19 +15,16 @@ d1 $ fast 2
d2 $ fast 2 $ gF1 $ gM1 d2 $ fast 2 $ gF1 $ gM1
$ midiOn "^43" (<| "~ s ~ s*<1!3 <2 [4 2]>>") $ midiOn "^43" (<| "~ s ~ s*<1!3 <2 [4 2]>>")
$ midiOff "^43" (<| "~ . s*<1!3 2> ~") $ midiOff "^43" (<| "~ . s*<1!3 2> ~")
$ "[realclaps:0]" $ "[realclaps:0,snare:56]"
-- # "h2ogmcp" -- # "h2ogmcp"
-- # gain (1.0 * "<[1]!16 [1 <1 <1 [1 0.93] 1 [0.9]>>]!16>") -- # gain (1.0 * "<[1]!16 [1 <1 <1 [1 0.93] 1 [0.9]>>]!16>")
# gain 1.2 # gain 1.2
-- # room 0.5 # dry 1.1
-- # delay "<0!15 0.6!1>"
-- # delayt 0.25
d3 $ gM1 $ gF1 d3 $ gM1 $ gF1
$ fast "<1!8 2!6 4>" $ "~ d ~ d ~ d ~ d ~ d*<1 2 2 1> ~ d ~ d ~ d"
$ "~ d ~ d ~ d ~ <d!12 [~ d]!3 [d d]>" # "h2ogmhh"
# "snare:34"
# hpf 7000 # hpf 7000
# gain 1.4 # gain 1.4
# room 0.3 # sz 0.5 # dry 0.9
d4 $ gF2 $ gM3 d4 $ gF2 $ gM3
$ note "[<as2 cs3 f3 cs3> <b2 ef3 fs3 b2>@7]" $ note "[<as2 cs3 f3 cs3> <b2 ef3 fs3 b2>@7]"
# "bassWarsaw" # cut 4 # "bassWarsaw" # cut 4
...@@ -61,9 +58,10 @@ d8 $ gF1 ...@@ -61,9 +58,10 @@ d8 $ gF1
$ midiOn "^92" (ply "<2 2 2 <4 8>>") $ midiOn "^92" (ply "<2 2 2 <4 8>>")
$ midiOff "^60" (mask "t(8,16,1)" . chop 16) $ midiOff "^60" (mask "t(8,16,1)" . chop 16)
$ chop 8 $ chop 8
$ fix (loopAt 2) (n 155)
$ loopAt 1 $ loopAt 1
$ midiOn "^36" (loopAt 0.5 . (# n 135)) $ midiOn "^36" (loopAt "0.5" . (# n "<135!3 [<135 155> 155]>"))
$ midiOn "^56" (# n 55) $ midiOn "^56" (# n 152)
$ "jungle_breaks:74" $ "jungle_breaks:74"
# cut 8 # cut 8
d10 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 @@ ...@@ -4,6 +4,13 @@
# Set variables # Set variables
SAVED_CPU_GOVERNOR="/tmp/saved_cpu_governor.txt" 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 # Display help
show_help() { show_help() {
...@@ -14,6 +21,8 @@ show_help() { ...@@ -14,6 +21,8 @@ show_help() {
echo "Options:" echo "Options:"
echo " --optimize Apply standard performance optimizations to running processes" echo " --optimize Apply standard performance optimizations to running processes"
echo " --extreme Apply extreme performance optimizations (for live performance)" 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 " --stop Reset system to normal operation"
echo " --check Check current system load and running processes" echo " --check Check current system load and running processes"
echo " --diagnose Run diagnostics to investigate audio stutters" echo " --diagnose Run diagnostics to investigate audio stutters"
...@@ -319,6 +328,73 @@ optimize_extreme() { ...@@ -319,6 +328,73 @@ optimize_extreme() {
echo "To reset when done, run: sudo $0 --stop" 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 # Function to set process priorities
set_priorities() { set_priorities() {
mode=$1 mode=$1
...@@ -395,7 +471,7 @@ set_priorities() { ...@@ -395,7 +471,7 @@ set_priorities() {
# Find OBS Studio if running # Find OBS Studio if running
OBS_PID=$(pgrep obs) OBS_PID=$(pgrep obs)
if [ ! -z "$OBS_PID" ]; then if [ ! -z "$OBS_PID" ]; then
if [ "$mode" = "extreme" ]; then if [ "$mode" != "standard" ]; then
renice -n 5 -p $OBS_PID renice -n 5 -p $OBS_PID
ionice -c 2 -n 5 -p $OBS_PID ionice -c 2 -n 5 -p $OBS_PID
else else
...@@ -449,8 +525,8 @@ set_priorities() { ...@@ -449,8 +525,8 @@ set_priorities() {
fi fi
if [ ! -z "$PULSAR_PIDS" ]; then if [ ! -z "$PULSAR_PIDS" ]; then
if [ "$mode" = "extreme" ]; then if [ "$mode" != "standard" ]; then
# Extreme: low but NOT idle — keeps editor usable while livecoding # Extreme/cool: low but NOT idle — keeps editor usable while livecoding
for PID in $PULSAR_PIDS; do for PID in $PULSAR_PIDS; do
renice -n 10 -p $PID >/dev/null 2>&1 renice -n 10 -p $PID >/dev/null 2>&1
ionice -c 2 -n 6 -p $PID >/dev/null 2>&1 ionice -c 2 -n 6 -p $PID >/dev/null 2>&1
...@@ -472,7 +548,7 @@ set_priorities() { ...@@ -472,7 +548,7 @@ set_priorities() {
# KDE Window Manager (X11 or Wayland) # KDE Window Manager (X11 or Wayland)
KWIN_PID=$(pgrep -x kwin_x11 || pgrep -x kwin_wayland) KWIN_PID=$(pgrep -x kwin_x11 || pgrep -x kwin_wayland)
if [ ! -z "$KWIN_PID" ]; then if [ ! -z "$KWIN_PID" ]; then
if [ "$mode" = "extreme" ]; then if [ "$mode" != "standard" ]; then
renice -n 10 -p $KWIN_PID >/dev/null 2>&1 renice -n 10 -p $KWIN_PID >/dev/null 2>&1
ionice -c 2 -n 6 -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)" echo "✓ Set KWin (PID $KWIN_PID) to nice 10 (low but not starved)"
...@@ -485,7 +561,7 @@ set_priorities() { ...@@ -485,7 +561,7 @@ set_priorities() {
# Plasma Shell # Plasma Shell
PLASMA_PID=$(pgrep -x plasmashell) PLASMA_PID=$(pgrep -x plasmashell)
if [ ! -z "$PLASMA_PID" ]; then if [ ! -z "$PLASMA_PID" ]; then
if [ "$mode" = "extreme" ]; then if [ "$mode" != "standard" ]; then
renice -n 15 -p $PLASMA_PID >/dev/null 2>&1 renice -n 15 -p $PLASMA_PID >/dev/null 2>&1
ionice -c 2 -n 7 -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" echo "✓ Set Plasma Shell (PID $PLASMA_PID) to nice 15"
...@@ -546,7 +622,21 @@ reset_system() { ...@@ -546,7 +622,21 @@ reset_system() {
if [ -d "/sys/devices/system/cpu/intel_pstate" ]; then if [ -d "/sys/devices/system/cpu/intel_pstate" ]; then
echo 0 > /sys/devices/system/cpu/intel_pstate/min_perf_pct 2>/dev/null 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 fi
# Restore NMI watchdog # Restore NMI watchdog
...@@ -648,6 +738,9 @@ case "$1" in ...@@ -648,6 +738,9 @@ case "$1" in
--extreme) --extreme)
optimize_extreme optimize_extreme
;; ;;
--cool)
optimize_cool
;;
--stop) --stop)
reset_system 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