Commit 5e3b0ad2 by PLN (Algolia)

feat(sextant): Freedom & control matrix + UPDATE cost bucket

Adds the edit/remaster/pull/permanence axis the cost model was missing:

- 4th cost bucket UPDATE: re-mastering an already-live release. Free on
  RouteNote; a new paid release on CD Baby; forces DistroKid to the Ultimate
  tier ($89.99/yr) for an in-place Audio Swap. Driven by a Remasters/year slider.
- Freedom & control matrix: per-option remaster / edit-metadata / takedown /
  if-you-stop-paying, glyph+text+colour (never hue alone). Surfaces the key fact
  that no DSP distributor swaps audio in place (a store rule), with Bandcamp the
  free-replace outlier.
- Ownership reframed: all options are non-exclusive (you keep 100% of masters);
  what differs is permanence, edit-freedom, portability.
- Softened the unverified RouteNote freeze-risk claim; flagged Premium pricing
  as contested pending verification.
parent 5f3f4d21
...@@ -18,11 +18,13 @@ the running as a real option (not pre-rejected on the no-sub rule). ...@@ -18,11 +18,13 @@ the running as a real option (not pre-rejected on the no-sub rule).
Rebuilt as a slider-driven economic model in `armada/ui` (Vite multi-page: the 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 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 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 releases, then computes PORT/KEEP/ADD/**UPDATE** cost + a decaying-spike revenue curve
option. Four distributors — RouteNote Free (baseline), RouteNote Premium, CD Baby per option. Four distributors — RouteNote Free (baseline), RouteNote Premium, CD Baby
(recommended), DistroKid — plus Bandcamp as an independent layer added on top of any (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 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. backlog, ~6/yr forward). Hand-rolled SVG break-even chart — no chart dependency. A
**Freedom & control** matrix answers the non-money question: can you remaster, edit,
pull, and stay up — and at what cost?
## Prise (findings / artifacts) ## Prise (findings / artifacts)
- `armada/ui/src/sextant/{model,data,format,Control,OptionRow,BreakEvenChart,Sextant}.tsx` - `armada/ui/src/sextant/{model,data,format,Control,OptionRow,BreakEvenChart,Sextant}.tsx`
...@@ -33,6 +35,15 @@ backlog, ~6/yr forward). Hand-rolled SVG break-even chart — no chart dependenc ...@@ -33,6 +35,15 @@ backlog, ~6/yr forward). Hand-rolled SVG break-even chart — no chart dependenc
near-identical net because the catalog stays yours. **Bandcamp digital sales are a 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. rounding error** (~$132 over 10 yr) — its worth is ownership, not revenue.
- **The remaster question reframes everything.** No DSP distributor swaps audio in
place — that's a *store* rule, not the distributor's. A remaster = a brand-new
release (new ISRC, lost date & plays) on CD Baby / RouteNote / TuneCore; DistroKid's
in-place Audio Swap needs the **Ultimate tier ($89.99/yr)** and only for minor
tweaks; **Bandcamp replaces the file free, anytime**. For a remaster-heavy salvage
op, that flips Bandcamp from "nice direct channel" to "master-of-record."
- Adding 2 remasters/yr pushes DistroKid to Ultimate and narrows its net lead over CD
Baby from ~$1.3k to ~$640 — the UPDATE bucket made a hidden cost visible.
## Sel (the shareable learning) ## Sel (the shareable learning)
- The break-even cadence everyone quotes (CD Baby vs DistroKid) **collapses to zero - 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 once you have a back-catalog to port** — owning-vs-renting stops being a price
......
import { Check, Minus, X } from 'lucide-react'
import { OPTIONS, BANDCAMP_FREEDOM, type Freedom, type FreedomFact, type FreedomLevel } from './model'
// Level → glyph + color, encoded redundantly (glyph + text + color, never hue alone).
const LEVEL: Record<FreedomLevel, { Icon: typeof Check; cls: string; sr: string }> = {
good: { Icon: Check, cls: 'text-ready', sr: 'yes' },
mixed: { Icon: Minus, cls: 'text-status-wip', sr: 'partial' },
bad: { Icon: X, cls: 'text-blocked', sr: 'no' },
}
const COLS: { key: keyof Freedom; label: string }[] = [
{ key: 'remaster', label: 'Remaster (swap audio)' },
{ key: 'edit', label: 'Edit metadata' },
{ key: 'pull', label: 'Pull / takedown' },
{ key: 'unpaid', label: 'If you stop paying' },
]
function Cell({ fact }: { fact: FreedomFact }) {
const { Icon, cls, sr } = LEVEL[fact.level]
return (
<td className="border-t border-hairline px-3 py-2.5 align-top">
<div className="flex gap-2">
<Icon size={14} className={`mt-px shrink-0 ${cls}`} aria-hidden />
<span className="sr-only">{sr}: </span>
<span className="text-[12px] leading-snug text-ink-muted">{fact.text}</span>
</div>
</td>
)
}
/** Rows = the four distributors + the Bandcamp layer; columns = the freedom axes
* that actually differ (rights ownership doesn't — see the note below). */
export function FreedomMatrix() {
const rows = [
...OPTIONS.map((o) => ({ id: o.id, name: o.name, recommended: o.recommended, freedom: o.freedom })),
{ id: 'bandcamp', name: 'Bandcamp (layer)', recommended: false, freedom: BANDCAMP_FREEDOM },
]
return (
<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">Freedom &amp; control</h2>
<p className="max-w-prose text-xs text-ink-muted">
Can you fix, change, or walk away, and at what cost? You keep 100% of your masters with all of
them — these are the axes that actually differ.
</p>
</div>
<div className="rounded-md border border-hairline bg-surface p-3 text-xs leading-relaxed text-ink-muted">
<span className="font-medium text-ink">No DSP distributor swaps audio in place</span> — that&apos;s a
store rule, not the distributor&apos;s. Everywhere except DistroKid Ultimate, a remaster is a brand-new
release (new ISRC, lost release date &amp; plays). The exception worth knowing:{' '}
<span className="text-melodic">Bandcamp lets you replace the file freely, anytime</span> — reason
enough to keep it as your master-of-record.
</div>
<div className="mt-3 -mx-1 overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse text-left">
<thead>
<tr>
<th className="px-3 pb-2 text-[11px] font-medium uppercase tracking-wide text-ink-faint">
Option
</th>
{COLS.map((c) => (
<th
key={c.key}
className="px-3 pb-2 text-[11px] font-medium uppercase tracking-wide text-ink-faint"
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className={r.recommended ? 'bg-magenta/[0.04]' : undefined}>
<th
scope="row"
className="border-t border-hairline px-3 py-2.5 align-top text-[12px] font-medium text-ink"
>
{r.name}
</th>
{COLS.map((c) => (
<Cell key={c.key} fact={r.freedom[c.key]} />
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-[11px] leading-snug text-ink-faint">
<span className="text-ink-muted">Ownership ≠ permanence.</span> All of these are non-exclusive: your
masters and copyrights stay 100% yours. What varies is <span className="text-ink-muted">permanence</span>{' '}
(does it stay up unpaid), <span className="text-ink-muted">edit-freedom</span> (can you fix it), and{' '}
<span className="text-ink-muted">portability</span> (you keep your ISRCs and can re-deliver elsewhere).
Fine print: CD Baby Pro (legacy) and TuneCore publishing-admin take a cut of <em>publishing</em>{' '}
royalties — opt-in, separate from distribution, never your masters.
</p>
</section>
)
}
...@@ -15,6 +15,7 @@ const SEGMENTS = [ ...@@ -15,6 +15,7 @@ const SEGMENTS = [
{ key: 'port' as const, label: 'Port', color: 'var(--color-melodic)' }, { key: 'port' as const, label: 'Port', color: 'var(--color-melodic)' },
{ key: 'keep' as const, label: 'Keep', color: 'var(--color-percs)' }, { key: 'keep' as const, label: 'Keep', color: 'var(--color-percs)' },
{ key: 'add' as const, label: 'Add', color: 'var(--color-tops)' }, { key: 'add' as const, label: 'Add', color: 'var(--color-tops)' },
{ key: 'update' as const, label: 'Update', color: 'var(--color-bass)' },
] ]
export function OptionRow({ r, isWinner, maxCost, maxProfit }: Props) { export function OptionRow({ r, isWinner, maxCost, maxProfit }: Props) {
......
...@@ -10,10 +10,11 @@ import { ...@@ -10,10 +10,11 @@ import {
import { PARVAGUES_CATALOG, MODEST_REVENUE } from './data' import { PARVAGUES_CATALOG, MODEST_REVENUE } from './data'
import { Slider, Stepper, Group, Toggle } from './Control' import { Slider, Stepper, Group, Toggle } from './Control'
import { OptionRow } from './OptionRow' import { OptionRow } from './OptionRow'
import { FreedomMatrix } from './FreedomMatrix'
import { BreakEvenChart } from './BreakEvenChart' import { BreakEvenChart } from './BreakEvenChart'
import { money, compact } from './format' import { money, compact } from './format'
const STORE = 'sextant_inputs_v3' const STORE = 'sextant_inputs_v4'
interface State { interface State {
cat: CatalogInputs cat: CatalogInputs
...@@ -160,6 +161,14 @@ export default function Sextant() { ...@@ -160,6 +161,14 @@ export default function Sextant() {
format={(v) => `${Math.round(v * 100)}% albums`} format={(v) => `${Math.round(v * 100)}% albums`}
onChange={(v) => setCat({ cadenceAlbumShare: v })} onChange={(v) => setCat({ cadenceAlbumShare: v })}
/> />
<Slider
label="Remasters / year"
value={cat.remastersPerYear}
min={0}
max={24}
hint="Re-mastering an already-live release. Free on RouteNote/Bandcamp; a new paid release on CD Baby; needs DistroKid Ultimate for an in-place swap."
onChange={(v) => setCat({ remastersPerYear: v })}
/>
<Slider label="Horizon" value={cat.horizonYears} min={1} max={25} unit=" yr" onChange={(v) => setCat({ horizonYears: v })} /> <Slider label="Horizon" value={cat.horizonYears} min={1} max={25} unit=" yr" onChange={(v) => setCat({ horizonYears: v })} />
</Group> </Group>
...@@ -211,6 +220,8 @@ export default function Sextant() { ...@@ -211,6 +220,8 @@ export default function Sextant() {
))} ))}
</div> </div>
<FreedomMatrix />
<section className="rounded-lg border border-hairline bg-raised p-4"> <section className="rounded-lg border border-hairline bg-raised p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> <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> <h2 className="text-sm font-semibold text-ink">The break-even, drawn</h2>
......
...@@ -12,6 +12,7 @@ export const PARVAGUES_CATALOG: CatalogInputs = { ...@@ -12,6 +12,7 @@ export const PARVAGUES_CATALOG: CatalogInputs = {
backlogTracksPerEp: 1, // all singles by default; bundle into EPs with the slider backlogTracksPerEp: 1, // all singles by default; bundle into EPs with the slider
cadencePerYear: 6, cadencePerYear: 6,
cadenceAlbumShare: 0.2, cadenceAlbumShare: 0.2,
remastersPerYear: 2, // the salvage op is remaster-heavy — re-delivering existing takes
horizonYears: 10, horizonYears: 10,
} }
......
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