@bbebee/plugin-visualizer-meters 0.0.0-stage → 0.1.1
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.
- package/BBeBee.plugin.json +30 -0
- package/LICENSE +21 -0
- package/README.md +234 -2
- package/dist/index.js +84 -0
- package/dist/ui-desktop.js +538 -0
- package/package.json +30 -4
|
@@ -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.1"
|
|
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,235 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Visualizer Meters — a third-party-style BBeBee plugin
|
|
2
2
|
|
|
3
|
-
This
|
|
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 — from CI, with provenance
|
|
147
|
+
|
|
148
|
+
Releases are published by `.github/workflows/publish.yml`
|
|
149
|
+
(**Actions → Publish to npm → Run workflow**, version as input). The
|
|
150
|
+
workflow stamps the version into `package.json` and
|
|
151
|
+
`BBeBee.plugin.json`, builds, tests, verifies the publication
|
|
152
|
+
identity, and runs `npm publish --provenance`.
|
|
153
|
+
|
|
154
|
+
Publishing from CI is not a convenience choice: npm **provenance**
|
|
155
|
+
attestations — the signed statement of which repository and workflow
|
|
156
|
+
built a version — are minted from the GitHub Actions OIDC
|
|
157
|
+
environment and cannot be added to a version after the fact. The
|
|
158
|
+
0.1.0 launch predates this (local machine, staged publishing) and
|
|
159
|
+
carries no attestation; every later release should come from the
|
|
160
|
+
workflow. The plugin client refuses an install whose attestation
|
|
161
|
+
names a different source repository than the registry entry's
|
|
162
|
+
`repo`, and registry CI checks official entries the same way.
|
|
163
|
+
|
|
164
|
+
One-time setup on npmjs.com (trusted publishing — no long-lived npm
|
|
165
|
+
token exists anywhere): package **@bbebee/plugin-visualizer-meters**
|
|
166
|
+
→ Settings → Trusted Publisher → GitHub Actions, with organization
|
|
167
|
+
`BBeBeeX`, repository `B_Be_Bee-visualizer-meters`, workflow filename
|
|
168
|
+
`publish.yml`.
|
|
169
|
+
|
|
170
|
+
After a release, update the registry entry's pin: set
|
|
171
|
+
`reviewedVersion` to the new version and `reviewedIntegrity` to that
|
|
172
|
+
version's `dist.integrity` from its npm packument.
|
|
173
|
+
|
|
174
|
+
Manual publishing (`npm run build && npm publish` from a checkout)
|
|
175
|
+
still works in a pinch, but the result has no provenance and should
|
|
176
|
+
be replaced by a CI release.
|
|
177
|
+
|
|
178
|
+
The npm package name and the registry entry's `npm` field are the same
|
|
179
|
+
string — `@bbebee/plugin-visualizer-meters` — while the manifest `id`
|
|
180
|
+
(and the registry entry's `id`) is the unscoped slug
|
|
181
|
+
`bbebee-plugin-visualizer-meters`. The install-time anti-spoofing checks
|
|
182
|
+
compare the pairs separately: the published package's name against the
|
|
183
|
+
entry's `npm`, its manifest `id` against the entry's `id`.
|
|
184
|
+
|
|
185
|
+
### 2. Register it in B_Be_Bee-registry
|
|
186
|
+
|
|
187
|
+
`registry-entry.json` in this repo is the schema-v2 entry for the
|
|
188
|
+
community registry. To list the plugin:
|
|
189
|
+
|
|
190
|
+
1. Copy it into the
|
|
191
|
+
[B_Be_Bee-registry](https://github.com/BBeBeeX/B_Be_Bee-registry) repo
|
|
192
|
+
as `plugins/bbebee-plugin-visualizer-meters.json` (the entry
|
|
193
|
+
filename must equal the entry `id`; registry validation rejects a
|
|
194
|
+
mismatch) and open a PR there.
|
|
195
|
+
2. Registry CI validates the entry: field shape, the `services`
|
|
196
|
+
whitelist, and — by fetching the npm packument — that the package in
|
|
197
|
+
the `npm` field exists and that its published manifest id and package
|
|
198
|
+
name match the entry. **Publish to npm first** (step 1); before the
|
|
199
|
+
first publish, validation reports, verbatim:
|
|
200
|
+
`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`.
|
|
201
|
+
|
|
202
|
+
Two details of the entry are easy to get wrong:
|
|
203
|
+
|
|
204
|
+
- **`services: ["visualizer"]`** — passes the registry whitelist because
|
|
205
|
+
`visualizer` is in the registry's core service-key list
|
|
206
|
+
(`scripts/lib/service-keys.ts` in B_Be_Bee-registry); service keys that
|
|
207
|
+
are *not* core keys are covered by the providing entry's `provides`.
|
|
208
|
+
- **The dependency names the visualizer's *registry* id.**
|
|
209
|
+
`dependencies: ["bbebeex-visualizer@^0.1.0"]` — a lowercase slug, a
|
|
210
|
+
different namespace from the manifest id (`@BBeBee/plugin-visualizer`)
|
|
211
|
+
that this package's own `BBeBee.plugin.json` `dependencies` uses. The
|
|
212
|
+
registry's dependency grammar (and the client's `parseDepSpecifier`)
|
|
213
|
+
accepts slugs only; writing the manifest-id form here is rejected
|
|
214
|
+
outright (`invalid dependency … — expected "<id>" or
|
|
215
|
+
"<id>@<semver-range>"`). The slug form works because a published
|
|
216
|
+
package's in-package manifest id is stamped to the registry id at
|
|
217
|
+
packaging time, which is exactly what the install-time anti-spoofing
|
|
218
|
+
check compares the entry id against. The dependency is also what makes
|
|
219
|
+
the `services` requirement satisfiable on a fresh install: the
|
|
220
|
+
installer resolves the closure, counts the visualizer entry's declared
|
|
221
|
+
`provides` in its plan-stage availability estimate, and re-verifies the
|
|
222
|
+
downloaded package's manifest `contributes.services` before activating
|
|
223
|
+
anything.
|
|
224
|
+
|
|
225
|
+
## Notes for authors copying this plugin
|
|
226
|
+
|
|
227
|
+
- Keep the two halves' shared code inside this package (`src/config.ts`,
|
|
228
|
+
`src/meters-math.ts` are bundled into each entry that imports them).
|
|
229
|
+
Cross-package value imports are not part of the third-party contract.
|
|
230
|
+
- If you need the host's exact shared-module list, the single source of
|
|
231
|
+
truth is `apps/desktop/renderer/host-modules.manifest.ts` in the BBeBee
|
|
232
|
+
repo; `build.mjs` mirrors it and says so.
|
|
233
|
+
- Log through `ctx.logger`, register cleanup through the fiber/effect
|
|
234
|
+
being disposed, and keep entry points `async` — the loader discards
|
|
235
|
+
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,32 @@
|
|
|
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
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": "^19.0.0",
|
|
20
|
+
"react-dom": "^19.0.0"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/BBeBeeX/B_Be_Bee-visualizer-meters"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "node build.mjs",
|
|
28
|
+
"test": "node --test test/meters-math.test.ts"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"version": "0.1.1"
|
|
32
|
+
}
|