@bbebee/plugin-visualizer-meters 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ {
2
+ "author": "BBeBee Team",
3
+ "capabilities": [],
4
+ "contributes": {
5
+ "slots": [
6
+ "now-playing.visualizer"
7
+ ]
8
+ },
9
+ "dependencies": [
10
+ "@BBeBee/plugin-visualizer"
11
+ ],
12
+ "description": "Example third-party plugin: spectrogram, spectrum and VU meter views driven by the visualizer service.",
13
+ "displayName": "Visualizer Meters",
14
+ "effect": null,
15
+ "enabled": true,
16
+ "engines": {
17
+ "BBeBee": "^0.1.0"
18
+ },
19
+ "entry": {
20
+ "main": "./dist/index.js",
21
+ "ui": {
22
+ "desktop": "./dist/ui-desktop.js"
23
+ }
24
+ },
25
+ "id": "bbebee-plugin-visualizer-meters",
26
+ "moduleId": "dsp",
27
+ "name": "@bbebee/plugin-visualizer-meters",
28
+ "systemId": "layer-4",
29
+ "version": "0.1.0"
30
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BBeBeeX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,210 @@
1
- # Temporary Holding Version
1
+ # Visualizer Meters — a third-party-style BBeBee plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This repository is the home of **`@bbebee/plugin-visualizer-meters`**, a
4
+ complete, publishable BBeBee plugin package written the way a third-party
5
+ author writes one: **one npm package, one `BBeBee.plugin.json`, a headless
6
+ entry and a desktop UI entry**, no monorepo wiring. It started life as the
7
+ reference example in the BBeBee repository (`examples/visualizer-meters/`)
8
+ and is maintained here as its own release repo — copy it as the starting
9
+ point for your own plugin.
10
+
11
+ It adds three audio meters to the now-playing screen — a **spectrogram**
12
+ (scrolling waterfall), a **spectrum** (log-spaced frequency bars) and a
13
+ **VU meter** (RMS + peak on a dB scale) — all driven by a service it does
14
+ not own: the `visualizer` service of the first-party
15
+ `@BBeBee/plugin-visualizer` plugin (published to npm as
16
+ `@bbebee/plugin-visualizer`).
17
+
18
+ ## What this plugin demonstrates
19
+
20
+ - **Depending on another plugin's service.** The plugin injects nothing at
21
+ install time it cannot name: the registry entry declares
22
+ `services: ["visualizer"]`, the manifest declares
23
+ `dependencies: ["@BBeBee/plugin-visualizer"]`, and at runtime both halves
24
+ treat the service as *optional* (`ctx.inject(['visualizer'], …)` in the
25
+ headless half; per-frame `serviceOf` reads in the UI) — if the visualizer
26
+ is disabled, the meters idle on a quiet baseline instead of breaking.
27
+ - **The combined package shape.** `entry.main` + `entry.ui.desktop` in one
28
+ package; the desktop loader composes them into a single plugin (headless
29
+ first, UI as a child fiber — a broken UI never takes the headless half
30
+ down with it).
31
+ - **Host-shared React.** The UI bundle keeps `react`, `react-dom`,
32
+ `react-dom/client`, `react/jsx-runtime` and `react/jsx-dev-runtime`
33
+ external so the shell's import map resolves them to the host's single
34
+ React instance; `package.json` declares the matching
35
+ `peerDependencies` (`^19.0.0`, warn-only at install time). `build.mjs`
36
+ fails the build if a React implementation ever slips into the bundle.
37
+ - **Zero capabilities.** `capabilities: []` — the plugin touches no fs,
38
+ network, or database. Its views draw with canvas 2D and host CSS
39
+ variables only; there is no ui-kit import.
40
+
41
+ ## Layout
42
+
43
+ ```
44
+ package.json name/version/files + peerDependencies (the npm face)
45
+ BBeBee.plugin.json the 15-field plugin manifest (name = npm package name, id = unscoped slug)
46
+ registry-entry.json the registry (schema v2) entry — see "Publishing"
47
+ build.mjs esbuild: src/index.ts → dist/index.js,
48
+ src/ui.tsx → dist/ui-desktop.js, plus the
49
+ host-sharing assertions on the UI bundle
50
+ src/
51
+ config.ts modes, palettes, defaults, config schema, resolver
52
+ meters-math.ts pure frame math (levels, VU ballistics, log bands,
53
+ waterfall ring buffer, palette LUTs)
54
+ index.ts headless def: config resolution, settings-card
55
+ contribution, optional visualizer watch
56
+ ui.tsx UI def: view + settings-card registration into the
57
+ `now-playing.visualizer` slot; the canvas meters
58
+ test/
59
+ meters-math.test.ts node:test unit tests for the pure modules
60
+ ```
61
+
62
+ ## How the visualizer service is consumed
63
+
64
+ The protocol contract (`VisualizerService`) is pull-based — there is no
65
+ frame event:
66
+
67
+ - `visualizer.getFrequencyData(buf)` fills a `Uint8Array` of
68
+ `fftSize / 2` byte bins (0 = silence). The service itself fills zeros
69
+ when no analyser is attached.
70
+ - `visualizer.getTimeDomainData(buf)` fills a `Uint8Array` of `fftSize`
71
+ bytes centred on 128 (silence).
72
+ - `visualizer.settings.fftSize` (default 128) is the sizing source for
73
+ both buffers; the view re-allocates when it changes.
74
+
75
+ The view runs one `requestAnimationFrame` loop, pulls one frame per tick,
76
+ and stops the loop on unmount. With no service (or no audio) the buffers
77
+ stay at their silence values and every meter draws its baseline.
78
+
79
+ ## The three meters
80
+
81
+ - **Spectrum** — bins are grouped into 48 **logarithmically** spaced bands
82
+ (`logBandEdges` in `meters-math.ts`; pitch perception is logarithmic, so
83
+ linear grouping would waste half the width on bass). Each band shows its
84
+ peak bin, coloured through the active palette.
85
+ - **Spectrogram** — every frame becomes one column of a ring buffer
86
+ (`WaterfallBuffer`, 240 columns); new frames enter at the right and
87
+ history scrolls left. Frequencies run bottom (low) to top (high), values
88
+ map through a pseudocolor LUT: `ember` is black → purple → orange →
89
+ yellow, alongside `ice` and `mono`. Rendering goes through an offscreen
90
+ `ImageData` strip scaled up to the canvas.
91
+ - **VU** — RMS and peak are computed from the time-domain frame and shown
92
+ on a −60…0 dB scale. Ballistics (`stepVu`, a pure function): the level
93
+ bar attacks instantly and releases at 0.9 dB/frame; the peak marker jumps
94
+ to a new peak, holds ≈45 frames, then falls at 0.35 dB/frame and never
95
+ below the current frame's peak.
96
+
97
+ Mode and palette can be switched from the segmented control on the view
98
+ itself and from the settings card (Settings → Playback → *Visualizer
99
+ Meters*); both write to one small store inside the UI bundle.
100
+
101
+ ## Configuration
102
+
103
+ The plugin accepts a config object (from its registry entry / install
104
+ record) of the shape declared in `src/config.ts` (`METERS_CONFIG_SCHEMA`):
105
+
106
+ ```json
107
+ { "mode": "spectrum" | "spectrogram" | "vu", "palette": "ember" | "ice" | "mono" }
108
+ ```
109
+
110
+ The headless half resolves the loader-passed config against the defaults
111
+ and logs the result. The composed UI def is registered without a config
112
+ argument (that is how the loader's composition works today), so the UI
113
+ seeds its session state from the same defaults and in-view changes are
114
+ session-local. Persisting them across restarts would require a host
115
+ storage capability (`db:own`), which this plugin deliberately does not
116
+ request.
117
+
118
+ ## Build & test
119
+
120
+ ```bash
121
+ npm install # once — esbuild + type packages from devDependencies
122
+ npm run build # → dist/index.js + dist/ui-desktop.js, with assertions
123
+ npm test # node --test over the pure modules — no framework needed
124
+ ```
125
+
126
+ `dist/` is a build product (gitignored); `package.json`'s `files` ships
127
+ exactly `BBeBee.plugin.json` + `dist`, which is what `npm publish` would
128
+ upload.
129
+
130
+ ## Trying it locally
131
+
132
+ 1. `npm install && npm run build` here.
133
+ 2. Either publish to npm (see below) and install it from the in-app plugin
134
+ market like any registry plugin, or sideload: copy this package
135
+ directory (with its built `dist/`) into the desktop app's
136
+ `installed-plugins/` directory and restart — the loader picks up
137
+ external packages from there, checks the manifest, and composes
138
+ `entry.main` + `entry.ui.desktop`.
139
+ 3. Play something and open the now-playing screen: the meters view appears
140
+ in the visualizer slot, after the built-in visualizer canvas. (The
141
+ visualizer plugin must be enabled for live data; without it the meters
142
+ draw their idle baseline.)
143
+
144
+ ## Publishing
145
+
146
+ ### 1. Publish the package to npm
147
+
148
+ ```bash
149
+ npm run build
150
+ npm publish
151
+ ```
152
+
153
+ The npm package name and the registry entry's `npm` field are the same
154
+ string — `@bbebee/plugin-visualizer-meters` — while the manifest `id`
155
+ (and the registry entry's `id`) is the unscoped slug
156
+ `bbebee-plugin-visualizer-meters`. The install-time anti-spoofing checks
157
+ compare the pairs separately: the published package's name against the
158
+ entry's `npm`, its manifest `id` against the entry's `id`.
159
+
160
+ ### 2. Register it in B_Be_Bee-registry
161
+
162
+ `registry-entry.json` in this repo is the schema-v2 entry for the
163
+ community registry. To list the plugin:
164
+
165
+ 1. Copy it into the
166
+ [B_Be_Bee-registry](https://github.com/BBeBeeX/B_Be_Bee-registry) repo
167
+ as `plugins/bbebeex-visualizer-meters.json` (entry filenames are
168
+ `{github-owner}-{name}.json`, a convention separate from the entry
169
+ `id` inside) and open a PR there.
170
+ 2. Registry CI validates the entry: field shape, the `services`
171
+ whitelist, and — by fetching the npm packument — that the package in
172
+ the `npm` field exists and that its published manifest id and package
173
+ name match the entry. **Publish to npm first** (step 1); before the
174
+ first publish, validation reports, verbatim:
175
+ `npm package "@bbebee/plugin-visualizer-meters" does not exist on the public npm registry (HTTP 404) — publish it before submitting, or fix the "npm" field`.
176
+
177
+ Two details of the entry are easy to get wrong:
178
+
179
+ - **`services: ["visualizer"]`** — passes the registry whitelist because
180
+ `visualizer` is in the registry's core service-key list
181
+ (`scripts/lib/service-keys.ts` in B_Be_Bee-registry); service keys that
182
+ are *not* core keys are covered by the providing entry's `provides`.
183
+ - **The dependency names the visualizer's *registry* id.**
184
+ `dependencies: ["bbebeex-visualizer@^0.1.0"]` — a lowercase slug, a
185
+ different namespace from the manifest id (`@BBeBee/plugin-visualizer`)
186
+ that this package's own `BBeBee.plugin.json` `dependencies` uses. The
187
+ registry's dependency grammar (and the client's `parseDepSpecifier`)
188
+ accepts slugs only; writing the manifest-id form here is rejected
189
+ outright (`invalid dependency … — expected "<id>" or
190
+ "<id>@<semver-range>"`). The slug form works because a published
191
+ package's in-package manifest id is stamped to the registry id at
192
+ packaging time, which is exactly what the install-time anti-spoofing
193
+ check compares the entry id against. The dependency is also what makes
194
+ the `services` requirement satisfiable on a fresh install: the
195
+ installer resolves the closure, counts the visualizer entry's declared
196
+ `provides` in its plan-stage availability estimate, and re-verifies the
197
+ downloaded package's manifest `contributes.services` before activating
198
+ anything.
199
+
200
+ ## Notes for authors copying this plugin
201
+
202
+ - Keep the two halves' shared code inside this package (`src/config.ts`,
203
+ `src/meters-math.ts` are bundled into each entry that imports them).
204
+ Cross-package value imports are not part of the third-party contract.
205
+ - If you need the host's exact shared-module list, the single source of
206
+ truth is `apps/desktop/renderer/host-modules.manifest.ts` in the BBeBee
207
+ repo; `build.mjs` mirrors it and says so.
208
+ - Log through `ctx.logger`, register cleanup through the fiber/effect
209
+ being disposed, and keep entry points `async` — the loader discards
210
+ disposers returned by plain non-async functions.
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ // src/config.ts
2
+ var METERS_MODES = ["spectrum", "spectrogram", "vu"];
3
+ var METERS_PALETTES = ["ember", "ice", "mono"];
4
+ var DEFAULT_METERS_CONFIG = {
5
+ mode: "spectrum",
6
+ palette: "ember"
7
+ };
8
+ var METERS_CONFIG_SCHEMA = {
9
+ type: "object",
10
+ additionalProperties: false,
11
+ properties: {
12
+ mode: {
13
+ type: "string",
14
+ enum: [...METERS_MODES],
15
+ default: DEFAULT_METERS_CONFIG.mode,
16
+ description: "Meter shown by default: spectrum bars, spectrogram waterfall, or VU meter."
17
+ },
18
+ palette: {
19
+ type: "string",
20
+ enum: [...METERS_PALETTES],
21
+ default: DEFAULT_METERS_CONFIG.palette,
22
+ description: "Pseudocolor palette used to render levels."
23
+ }
24
+ }
25
+ };
26
+ function resolveMetersConfig(input) {
27
+ if (input === null || typeof input !== "object") {
28
+ return { ...DEFAULT_METERS_CONFIG };
29
+ }
30
+ const raw = input;
31
+ return {
32
+ mode: isMode(raw.mode) ? raw.mode : DEFAULT_METERS_CONFIG.mode,
33
+ palette: isPalette(raw.palette) ? raw.palette : DEFAULT_METERS_CONFIG.palette
34
+ };
35
+ }
36
+ function isMode(value) {
37
+ return typeof value === "string" && METERS_MODES.includes(value);
38
+ }
39
+ function isPalette(value) {
40
+ return typeof value === "string" && METERS_PALETTES.includes(value);
41
+ }
42
+
43
+ // src/index.ts
44
+ var name = "bbebee-plugin-visualizer-meters";
45
+ var inject = [];
46
+ var configSchema = METERS_CONFIG_SCHEMA;
47
+ async function apply(ctx, config) {
48
+ const resolved = resolveMetersConfig(config);
49
+ ctx.logger.info(
50
+ `visualizer-meters: loaded (default mode=${resolved.mode}, palette=${resolved.palette})`
51
+ );
52
+ ctx.inject(["visualizer"], (scoped) => {
53
+ const fftSize = scoped.visualizer?.settings?.fftSize;
54
+ scoped.logger.info(
55
+ `visualizer-meters: visualizer service available${fftSize ? ` (fftSize=${fftSize})` : ""}`
56
+ );
57
+ });
58
+ ctx.inject(
59
+ ["ui"],
60
+ (scoped) => scoped.effect(function* () {
61
+ yield scoped.ui?.contribute({
62
+ kind: "settings",
63
+ id: "visualizer-meters.settings",
64
+ section: "playback",
65
+ title: "Visualizer Meters",
66
+ description: "Default mode and palette for the spectrogram / spectrum / VU meter views",
67
+ display: "card",
68
+ order: 90
69
+ });
70
+ }, "visualizer-meters-settings-contribution")
71
+ );
72
+ return () => {
73
+ ctx.logger.info("visualizer-meters: disposed");
74
+ };
75
+ }
76
+ var index_default = { name, inject, apply };
77
+ export {
78
+ DEFAULT_METERS_CONFIG,
79
+ apply,
80
+ configSchema,
81
+ index_default as default,
82
+ inject,
83
+ name
84
+ };
@@ -0,0 +1,538 @@
1
+ // src/ui.tsx
2
+ import {
3
+ createElement as h,
4
+ useEffect,
5
+ useRef,
6
+ useSyncExternalStore
7
+ } from "react";
8
+
9
+ // src/config.ts
10
+ var METERS_MODES = ["spectrum", "spectrogram", "vu"];
11
+ var METERS_PALETTES = ["ember", "ice", "mono"];
12
+ var DEFAULT_METERS_CONFIG = {
13
+ mode: "spectrum",
14
+ palette: "ember"
15
+ };
16
+ var METERS_CONFIG_SCHEMA = {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ mode: {
21
+ type: "string",
22
+ enum: [...METERS_MODES],
23
+ default: DEFAULT_METERS_CONFIG.mode,
24
+ description: "Meter shown by default: spectrum bars, spectrogram waterfall, or VU meter."
25
+ },
26
+ palette: {
27
+ type: "string",
28
+ enum: [...METERS_PALETTES],
29
+ default: DEFAULT_METERS_CONFIG.palette,
30
+ description: "Pseudocolor palette used to render levels."
31
+ }
32
+ }
33
+ };
34
+
35
+ // src/meters-math.ts
36
+ var DB_FLOOR = -60;
37
+ var clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
38
+ function byteToSample(byte) {
39
+ return clamp((byte - 128) / 128, -1, 1);
40
+ }
41
+ function computeRms(timeDomain) {
42
+ if (timeDomain.length === 0) return 0;
43
+ let sumSquares = 0;
44
+ for (let i = 0; i < timeDomain.length; i++) {
45
+ const sample = byteToSample(timeDomain[i]);
46
+ sumSquares += sample * sample;
47
+ }
48
+ return Math.sqrt(sumSquares / timeDomain.length);
49
+ }
50
+ function computePeak(timeDomain) {
51
+ let peak = 0;
52
+ for (let i = 0; i < timeDomain.length; i++) {
53
+ const magnitude = Math.abs(byteToSample(timeDomain[i]));
54
+ if (magnitude > peak) peak = magnitude;
55
+ }
56
+ return peak;
57
+ }
58
+ function levelToDb(level, floorDb = DB_FLOOR) {
59
+ if (!(level > 0)) return floorDb;
60
+ return clamp(20 * Math.log10(level), floorDb, 0);
61
+ }
62
+ function dbToFraction(db, floorDb = DB_FLOOR) {
63
+ return clamp((db - floorDb) / (0 - floorDb), 0, 1);
64
+ }
65
+ var DEFAULT_VU_OPTIONS = {
66
+ releaseDbPerFrame: 0.9,
67
+ peakFallDbPerFrame: 0.35,
68
+ peakHoldFrames: 45
69
+ };
70
+ function createVuState(floorDb = DB_FLOOR) {
71
+ return { levelDb: floorDb, peakDb: floorDb, peakHoldFramesLeft: 0 };
72
+ }
73
+ function stepVu(state, rmsDb, peakDb, options = DEFAULT_VU_OPTIONS, floorDb = DB_FLOOR) {
74
+ const nextRms = clamp(rmsDb, floorDb, 0);
75
+ const nextPeakIn = clamp(peakDb, floorDb, 0);
76
+ const levelDb = nextRms >= state.levelDb ? nextRms : Math.max(nextRms, state.levelDb - options.releaseDbPerFrame);
77
+ let nextPeakDb = state.peakDb;
78
+ let holdLeft = state.peakHoldFramesLeft;
79
+ if (nextPeakIn >= state.peakDb) {
80
+ nextPeakDb = nextPeakIn;
81
+ holdLeft = options.peakHoldFrames;
82
+ } else if (holdLeft > 0) {
83
+ holdLeft -= 1;
84
+ } else {
85
+ nextPeakDb = Math.max(nextPeakIn, state.peakDb - options.peakFallDbPerFrame);
86
+ }
87
+ return { levelDb, peakDb: clamp(nextPeakDb, floorDb, 0), peakHoldFramesLeft: holdLeft };
88
+ }
89
+ function logBandEdges(binCount, bandCount) {
90
+ const usable = Math.max(0, binCount - 1);
91
+ const bands = Math.max(1, Math.min(bandCount, Math.max(1, usable)));
92
+ const edges = [];
93
+ let start = 1;
94
+ for (let i = 0; i < bands; i++) {
95
+ const t = (i + 1) / bands;
96
+ const end = i === bands - 1 ? binCount : Math.round(Math.exp(Math.log(1) + Math.log(binCount) * t));
97
+ const clampedEnd = Math.min(binCount, Math.max(start + 1, end));
98
+ edges.push([start, clampedEnd]);
99
+ start = clampedEnd;
100
+ }
101
+ return edges;
102
+ }
103
+ function aggregateBands(frequencyData, edges) {
104
+ const out = new Float32Array(edges.length);
105
+ for (let b = 0; b < edges.length; b++) {
106
+ const [start, end] = edges[b];
107
+ let max = 0;
108
+ const stop = Math.min(end, frequencyData.length);
109
+ for (let i = start; i < stop; i++) {
110
+ const value = frequencyData[i];
111
+ if (value > max) max = value;
112
+ }
113
+ out[b] = max / 255;
114
+ }
115
+ return out;
116
+ }
117
+ var WaterfallBuffer = class {
118
+ width;
119
+ height;
120
+ columns;
121
+ head = 0;
122
+ filled = 0;
123
+ constructor(width, height) {
124
+ this.width = Math.max(1, Math.floor(width));
125
+ this.height = Math.max(1, Math.floor(height));
126
+ this.columns = Array.from({ length: this.width }, () => new Uint8Array(this.height));
127
+ }
128
+ /** Number of frames currently stored (≤ width). */
129
+ get size() {
130
+ return this.filled;
131
+ }
132
+ /** Stores one frame (a column of `height` byte values; extra values are dropped). */
133
+ push(frame) {
134
+ const column = this.columns[this.head];
135
+ const n = Math.min(this.height, frame.length);
136
+ for (let i = 0; i < n; i++) column[i] = frame[i];
137
+ for (let i = n; i < this.height; i++) column[i] = 0;
138
+ this.head = (this.head + 1) % this.width;
139
+ if (this.filled < this.width) this.filled += 1;
140
+ }
141
+ /** Returns the frame `age` pushes ago (0 = newest), or undefined if not stored yet. */
142
+ columnAtAge(age) {
143
+ if (age < 0 || age >= this.filled) return void 0;
144
+ const index = (this.head - 1 - age + this.width * 2) % this.width;
145
+ return this.columns[index];
146
+ }
147
+ clear() {
148
+ this.head = 0;
149
+ this.filled = 0;
150
+ for (const column of this.columns) column.fill(0);
151
+ }
152
+ };
153
+ var PALETTE_STOPS = {
154
+ ember: [
155
+ [0, 0, 0],
156
+ [64, 0, 128],
157
+ [255, 96, 0],
158
+ [255, 224, 64]
159
+ ],
160
+ ice: [
161
+ [0, 0, 0],
162
+ [0, 32, 96],
163
+ [0, 160, 200],
164
+ [235, 250, 255]
165
+ ],
166
+ mono: [
167
+ [0, 0, 0],
168
+ [96, 96, 96],
169
+ [192, 192, 192],
170
+ [255, 255, 255]
171
+ ]
172
+ };
173
+ function buildPaletteLut(stops) {
174
+ const lut = new Uint8Array(256 * 3);
175
+ if (stops.length === 0) return lut;
176
+ if (stops.length === 1) {
177
+ const [r, g, b] = stops[0];
178
+ for (let i = 0; i < 256; i++) {
179
+ lut[i * 3] = r;
180
+ lut[i * 3 + 1] = g;
181
+ lut[i * 3 + 2] = b;
182
+ }
183
+ return lut;
184
+ }
185
+ const segments = stops.length - 1;
186
+ for (let i = 0; i < 256; i++) {
187
+ const t = i / 255 * segments;
188
+ const segment = Math.min(segments - 1, Math.floor(t));
189
+ const local = t - segment;
190
+ const a = stops[segment];
191
+ const b = stops[segment + 1];
192
+ lut[i * 3] = Math.round(a[0] + (b[0] - a[0]) * local);
193
+ lut[i * 3 + 1] = Math.round(a[1] + (b[1] - a[1]) * local);
194
+ lut[i * 3 + 2] = Math.round(a[2] + (b[2] - a[2]) * local);
195
+ }
196
+ return lut;
197
+ }
198
+ function lutColor(lut, byteValue) {
199
+ const index = clamp(Math.round(byteValue), 0, 255) * 3;
200
+ return [lut[index], lut[index + 1], lut[index + 2]];
201
+ }
202
+
203
+ // src/ui.tsx
204
+ import { jsx, jsxs } from "react/jsx-runtime";
205
+ function serviceOf(ctx, key) {
206
+ try {
207
+ const fromReflect = ctx.reflect?.get(key, false);
208
+ if (fromReflect !== void 0 && fromReflect !== null) return fromReflect;
209
+ } catch {
210
+ }
211
+ const value = ctx[key];
212
+ return value === void 0 || value === null ? void 0 : value;
213
+ }
214
+ var uiSnapshot = { ...DEFAULT_METERS_CONFIG };
215
+ var uiListeners = /* @__PURE__ */ new Set();
216
+ function setUiState(patch) {
217
+ uiSnapshot = { ...uiSnapshot, ...patch };
218
+ for (const listener of uiListeners) listener();
219
+ }
220
+ function subscribeUi(listener) {
221
+ uiListeners.add(listener);
222
+ return () => {
223
+ uiListeners.delete(listener);
224
+ };
225
+ }
226
+ function useMetersUiState() {
227
+ return useSyncExternalStore(subscribeUi, () => uiSnapshot, () => uiSnapshot);
228
+ }
229
+ var MODE_LABELS = {
230
+ spectrum: "Spectrum",
231
+ spectrogram: "Spectrogram",
232
+ vu: "VU"
233
+ };
234
+ function ModeSwitch({ value, onChange }) {
235
+ return /* @__PURE__ */ jsx(
236
+ "div",
237
+ {
238
+ role: "tablist",
239
+ "aria-label": "Meter mode",
240
+ style: { display: "inline-flex", gap: 2, padding: 2, borderRadius: 6, border: "1px solid var(--border-subtle, rgba(127, 127, 127, 0.35))" },
241
+ children: METERS_MODES.map((mode) => /* @__PURE__ */ jsx(
242
+ "button",
243
+ {
244
+ role: "tab",
245
+ "aria-selected": mode === value,
246
+ onClick: () => onChange(mode),
247
+ style: {
248
+ border: "none",
249
+ borderRadius: 4,
250
+ padding: "2px 8px",
251
+ fontSize: 12,
252
+ cursor: "pointer",
253
+ color: "var(--color-text-primary, inherit)",
254
+ background: mode === value ? "var(--color-primary, #5f87ff)" : "transparent"
255
+ },
256
+ children: MODE_LABELS[mode]
257
+ },
258
+ mode
259
+ ))
260
+ }
261
+ );
262
+ }
263
+ function PaletteSwitch({
264
+ value,
265
+ onChange
266
+ }) {
267
+ return /* @__PURE__ */ jsxs("label", { style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12 }, children: [
268
+ "Palette",
269
+ /* @__PURE__ */ jsx(
270
+ "select",
271
+ {
272
+ value,
273
+ onChange: (event) => onChange(event.target.value),
274
+ style: {
275
+ fontSize: 12,
276
+ color: "var(--color-text-primary, inherit)",
277
+ background: "transparent",
278
+ border: "1px solid var(--border-subtle, rgba(127, 127, 127, 0.35))",
279
+ borderRadius: 4,
280
+ padding: "2px 4px"
281
+ },
282
+ children: METERS_PALETTES.map((palette) => /* @__PURE__ */ jsx("option", { value: palette, children: palette }, palette))
283
+ }
284
+ )
285
+ ] });
286
+ }
287
+ var SPECTRUM_BANDS = 48;
288
+ var WATERFALL_COLUMNS = 240;
289
+ var DB_TICKS = [-40, -30, -20, -12, -9, -6, -3, 0];
290
+ function MetersView({ ctx, height = 132 }) {
291
+ const canvasRef = useRef(null);
292
+ const uiState = useMetersUiState();
293
+ const uiStateRef = useRef(uiState);
294
+ uiStateRef.current = uiState;
295
+ useEffect(() => {
296
+ const canvas = canvasRef.current;
297
+ if (!canvas) return;
298
+ const g = canvas.getContext("2d");
299
+ if (!g) return;
300
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
301
+ let cssWidth = 320;
302
+ let cssHeight = height;
303
+ const resize = () => {
304
+ const rect = canvas.getBoundingClientRect();
305
+ cssWidth = rect.width || 320;
306
+ cssHeight = rect.height || height;
307
+ canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
308
+ canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
309
+ };
310
+ resize();
311
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => resize()) : null;
312
+ observer?.observe(canvas);
313
+ let fftSize = 0;
314
+ let freq = new Uint8Array(0);
315
+ let time = new Uint8Array(0);
316
+ let waterfall = null;
317
+ let waterfallBins = 0;
318
+ let vu = createVuState();
319
+ const lutCache = /* @__PURE__ */ new Map();
320
+ const lutFor = (palette) => {
321
+ let lut = lutCache.get(palette);
322
+ if (!lut) {
323
+ lut = buildPaletteLut(PALETTE_STOPS[palette] ?? PALETTE_STOPS.ember);
324
+ lutCache.set(palette, lut);
325
+ }
326
+ return lut;
327
+ };
328
+ const strip = document.createElement("canvas");
329
+ const stripCtx = strip.getContext("2d");
330
+ const drawBaseline = (w, hgt) => {
331
+ g.strokeStyle = "rgba(127, 127, 127, 0.45)";
332
+ g.lineWidth = 1;
333
+ g.beginPath();
334
+ g.moveTo(0, hgt - 0.5);
335
+ g.lineTo(w, hgt - 0.5);
336
+ g.stroke();
337
+ };
338
+ const drawSpectrum = (w, hgt, lut) => {
339
+ const edges = logBandEdges(freq.length, SPECTRUM_BANDS);
340
+ const bands = aggregateBands(freq, edges);
341
+ const gap = 2;
342
+ const barWidth = Math.max(1, (w - (bands.length - 1) * gap) / bands.length);
343
+ for (let i = 0; i < bands.length; i++) {
344
+ const value = bands[i];
345
+ const barHeight = Math.max(value > 0 ? 2 : 0, value * (hgt - 4));
346
+ if (barHeight <= 0) continue;
347
+ const [r, gg, b] = lutColor(lut, value * 255);
348
+ g.fillStyle = `rgb(${r}, ${gg}, ${b})`;
349
+ g.fillRect(i * (barWidth + gap), hgt - barHeight, barWidth, barHeight);
350
+ }
351
+ drawBaseline(w, hgt);
352
+ };
353
+ const drawSpectrogram = (w, hgt, lut) => {
354
+ if (!waterfall || !stripCtx) {
355
+ drawBaseline(w, hgt);
356
+ return;
357
+ }
358
+ const bins = waterfallBins;
359
+ if (strip.width !== WATERFALL_COLUMNS || strip.height !== bins) {
360
+ strip.width = WATERFALL_COLUMNS;
361
+ strip.height = Math.max(1, bins);
362
+ }
363
+ const image = stripCtx.createImageData(strip.width, strip.height);
364
+ for (let x = 0; x < strip.width; x++) {
365
+ const column = waterfall.columnAtAge(strip.width - 1 - x);
366
+ for (let bin = 0; bin < bins; bin++) {
367
+ const row = bins - 1 - bin;
368
+ const offset = (row * strip.width + x) * 4;
369
+ const value = column ? column[bin] ?? 0 : 0;
370
+ const [r, gg, b] = lutColor(lut, value);
371
+ image.data[offset] = r;
372
+ image.data[offset + 1] = gg;
373
+ image.data[offset + 2] = b;
374
+ image.data[offset + 3] = 255;
375
+ }
376
+ }
377
+ stripCtx.putImageData(image, 0, 0);
378
+ g.imageSmoothingEnabled = true;
379
+ g.clearRect(0, 0, w, hgt);
380
+ g.drawImage(strip, 0, 0, w, hgt);
381
+ };
382
+ const drawVu = (w, hgt, lut) => {
383
+ const midY = Math.floor(hgt / 2);
384
+ const trackY = midY - 7;
385
+ const trackHeight = 14;
386
+ g.fillStyle = "rgba(127, 127, 127, 0.25)";
387
+ g.fillRect(0, trackY, w, trackHeight);
388
+ g.fillStyle = "rgba(127, 127, 127, 0.8)";
389
+ g.font = "10px sans-serif";
390
+ for (const db of DB_TICKS) {
391
+ const x = dbToFraction(db) * w;
392
+ g.fillRect(x, trackY + trackHeight + 2, 1, 4);
393
+ g.fillText(`${db}`, x + 2, trackY + trackHeight + 14);
394
+ }
395
+ const levelFraction = dbToFraction(vu.levelDb);
396
+ if (levelFraction > 0) {
397
+ const [r, gg, b] = lutColor(lut, levelFraction * 255);
398
+ g.fillStyle = `rgb(${r}, ${gg}, ${b})`;
399
+ g.fillRect(0, trackY, levelFraction * w, trackHeight);
400
+ }
401
+ const peakX = dbToFraction(vu.peakDb) * w;
402
+ g.fillStyle = "rgba(255, 80, 80, 0.95)";
403
+ g.fillRect(Math.max(0, peakX - 1), trackY - 3, 2, trackHeight + 6);
404
+ g.fillStyle = "rgba(127, 127, 127, 0.9)";
405
+ const label = (db) => db <= DB_FLOOR ? "-\u221E dB" : `${db.toFixed(1)} dB`;
406
+ g.fillText(`RMS ${label(vu.levelDb)} peak ${label(vu.peakDb)}`, 0, 12);
407
+ if (vu.levelDb <= DB_FLOOR && vu.peakDb <= DB_FLOOR) drawBaseline(w, hgt);
408
+ };
409
+ let animId = 0;
410
+ const render = () => {
411
+ animId = requestAnimationFrame(render);
412
+ const { mode, palette } = uiStateRef.current;
413
+ const lut = lutFor(palette);
414
+ const visualizer = serviceOf(ctx, "visualizer");
415
+ const targetFft = clampFftSize(visualizer?.settings?.fftSize ?? 128);
416
+ if (targetFft !== fftSize) {
417
+ fftSize = targetFft;
418
+ freq = new Uint8Array(fftSize / 2);
419
+ time = new Uint8Array(fftSize);
420
+ waterfall = null;
421
+ waterfallBins = 0;
422
+ }
423
+ if (visualizer) {
424
+ visualizer.getFrequencyData(freq);
425
+ visualizer.getTimeDomainData(time);
426
+ } else {
427
+ freq.fill(0);
428
+ time.fill(128);
429
+ }
430
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
431
+ g.clearRect(0, 0, cssWidth, cssHeight);
432
+ if (mode === "spectrum") {
433
+ drawSpectrum(cssWidth, cssHeight, lut);
434
+ } else if (mode === "spectrogram") {
435
+ if (!waterfall || waterfallBins !== freq.length) {
436
+ waterfall = new WaterfallBuffer(WATERFALL_COLUMNS, freq.length);
437
+ waterfallBins = freq.length;
438
+ }
439
+ waterfall.push(freq);
440
+ drawSpectrogram(cssWidth, cssHeight, lut);
441
+ } else {
442
+ vu = stepVu(vu, levelToDb(computeRms(time)), levelToDb(computePeak(time)));
443
+ drawVu(cssWidth, cssHeight, lut);
444
+ }
445
+ };
446
+ animId = requestAnimationFrame(render);
447
+ return () => {
448
+ if (animId) cancelAnimationFrame(animId);
449
+ observer?.disconnect();
450
+ };
451
+ }, [ctx, height]);
452
+ return /* @__PURE__ */ jsxs(
453
+ "div",
454
+ {
455
+ style: {
456
+ display: "flex",
457
+ flexDirection: "column",
458
+ gap: 6,
459
+ width: "100%",
460
+ maxWidth: 560,
461
+ color: "var(--color-text-primary, inherit)"
462
+ },
463
+ children: [
464
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }, children: [
465
+ /* @__PURE__ */ jsx(ModeSwitch, { value: uiState.mode, onChange: (mode) => setUiState({ mode }) }),
466
+ /* @__PURE__ */ jsx(PaletteSwitch, { value: uiState.palette, onChange: (palette) => setUiState({ palette }) })
467
+ ] }),
468
+ /* @__PURE__ */ jsx(
469
+ "canvas",
470
+ {
471
+ ref: canvasRef,
472
+ style: {
473
+ width: "100%",
474
+ height,
475
+ display: "block",
476
+ borderRadius: 6,
477
+ border: "1px solid var(--border-subtle, rgba(127, 127, 127, 0.35))",
478
+ background: "var(--color-bg-app, transparent)"
479
+ }
480
+ }
481
+ )
482
+ ]
483
+ }
484
+ );
485
+ }
486
+ function clampFftSize(value) {
487
+ if (!Number.isFinite(value) || value <= 0) return 128;
488
+ const power = Math.round(Math.log2(value));
489
+ return Math.min(2048, Math.max(32, 2 ** power));
490
+ }
491
+ function MetersSettingsCard({ ctx }) {
492
+ const uiState = useMetersUiState();
493
+ const visualizer = serviceOf(ctx, "visualizer");
494
+ return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10, maxWidth: 560 }, children: [
495
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }, children: [
496
+ /* @__PURE__ */ jsx(ModeSwitch, { value: uiState.mode, onChange: (mode) => setUiState({ mode }) }),
497
+ /* @__PURE__ */ jsx(PaletteSwitch, { value: uiState.palette, onChange: (palette) => setUiState({ palette }) })
498
+ ] }),
499
+ /* @__PURE__ */ jsxs("p", { style: { margin: 0, fontSize: 12, opacity: 0.75 }, children: [
500
+ "These defaults seed the Visualizer Meters view in the now-playing screen. The",
501
+ " ",
502
+ /* @__PURE__ */ jsx("code", { children: "visualizer" }),
503
+ " service is",
504
+ " ",
505
+ visualizer ? "available" : "not available \u2014 the meters will show a quiet baseline",
506
+ ". Changes are session-local in this example (see the package README)."
507
+ ] })
508
+ ] });
509
+ }
510
+ var name = "bbebee-plugin-visualizer-meters-ui";
511
+ var inject = ["ui"];
512
+ function bound(ctx, Screen) {
513
+ return function Bound(props) {
514
+ return h(Screen, { ...props, ctx });
515
+ };
516
+ }
517
+ async function apply(ctx) {
518
+ ctx.logger.info("visualizer-meters ui: loaded");
519
+ return ctx.effect(function* () {
520
+ yield ctx.ui.registerView("visualizer-meters.canvas", bound(ctx, MetersView));
521
+ yield ctx.ui.registerView("visualizer-meters.settings", bound(ctx, MetersSettingsCard));
522
+ yield ctx.ui.contribute({
523
+ kind: "slot",
524
+ id: "visualizer-meters.canvas",
525
+ slot: "now-playing.visualizer",
526
+ order: 20
527
+ });
528
+ }, "visualizer-meters-ui");
529
+ }
530
+ var ui_default = { name, inject, apply };
531
+ export {
532
+ MetersSettingsCard,
533
+ MetersView,
534
+ apply,
535
+ ui_default as default,
536
+ inject,
537
+ name
538
+ };
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
+ "author": "BBeBee Team",
3
+ "description": "BBeBee example plugin (third-party style): spectrogram, spectrum and VU meters powered by the visualizer service.",
4
+ "devDependencies": {
5
+ "@types/node": "^22.0.0",
6
+ "@types/react": "^19.2.0",
7
+ "@types/react-dom": "^19.2.0",
8
+ "esbuild": "^0.28.0",
9
+ "typescript": "^5.9.0"
10
+ },
11
+ "files": [
12
+ "BBeBee.plugin.json",
13
+ "dist"
14
+ ],
15
+ "license": "MIT",
16
+ "main": "dist/index.js",
2
17
  "name": "@bbebee/plugin-visualizer-meters",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
18
+ "peerDependencies": {
19
+ "react": "^19.0.0",
20
+ "react-dom": "^19.0.0"
21
+ },
22
+ "scripts": {
23
+ "build": "node build.mjs",
24
+ "test": "node --test test/meters-math.test.ts"
25
+ },
26
+ "type": "module",
27
+ "version": "0.1.0"
28
+ }