web3-radio 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.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # web3-radio
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
+ Live audio streaming on [Swarm](https://www.ethswarm.org). Drop a publisher or a player into any web page — plain JavaScript, React, Svelte, whatever you use.
4
+
5
+ **Try it:** [go live](https://bzz.limo/bzz/9b1fd340700c4408818f6644cb846bd5d5f85574e7b36f3df0b06e72de44c4ee/), then share the listener link it gives you. The [player](https://bzz.limo/bzz/fb7a8f6cc9135f303991ea80dd7ce2ef1c50ea553f6621ba475b5adaa6457ebe/) takes `?owner=…&stream=…`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install web3-radio
11
+ ```
12
+
13
+ ## Go live
14
+
15
+ ```html
16
+ <div id="radio"></div>
17
+
18
+ <script type="module">
19
+ import { Publisher } from 'web3-radio'
20
+
21
+ const publisher = new Publisher('#radio', { stream: 'my-show' })
22
+
23
+ console.log('Listeners need this owner address:', publisher.owner)
24
+ </script>
25
+ ```
26
+
27
+ Press the button, allow the microphone, and you are on air.
28
+
29
+ A stream is identified by its **owner** (the publisher's address) and its **stream** name. Without a `privateKey`, a new identity is generated on every page load. Pass your own to keep the same address:
30
+
31
+ ```js
32
+ new Publisher('#radio', { stream: 'my-show', privateKey: '0x…' })
33
+ ```
34
+
35
+ ## Listen
36
+
37
+ ```html
38
+ <div id="radio"></div>
39
+
40
+ <script type="module">
41
+ import { Player } from 'web3-radio'
42
+
43
+ new Player('#radio', { owner: '0x…', stream: 'my-show' })
44
+ </script>
45
+ ```
46
+
47
+ ## Theme
48
+
49
+ Both components accept a `theme`. Every field is optional.
50
+
51
+ ```js
52
+ new Player('#radio', {
53
+ owner: '0x…',
54
+ stream: 'my-show',
55
+ theme: {
56
+ background: '#16161d',
57
+ foreground: '#f4f4f6',
58
+ muted: '#8b8b99',
59
+ accent: '#ff5c39',
60
+ font: 'system-ui, sans-serif',
61
+ radius: '16px',
62
+ },
63
+ })
64
+ ```
65
+
66
+ ## API
67
+
68
+ ### `new Publisher(target, options)`
69
+
70
+ | Option | Default | Description |
71
+ | ---------------- | ------------------ | ------------------------------------------------------ |
72
+ | `stream` | — | Stream name |
73
+ | `privateKey` | random | Hex secp256k1 key that signs the stream |
74
+ | `stamp` | — | Postage batch ID; not needed with the default gateway |
75
+ | `gateway` | `https://bzz.limo` | Swarm gateway or Bee node URL |
76
+ | `segmentSeconds` | `2` | Length of each audio segment |
77
+ | `bitrate` | `64000` | Opus bitrate in bits per second |
78
+ | `theme` | — | Partial `Theme` |
79
+
80
+ `publisher.owner`, `publisher.live`, `publisher.start()`, `publisher.stop()`, `publisher.destroy()`
81
+
82
+ ### `new Player(target, options)`
83
+
84
+ | Option | Default | Description |
85
+ | --------- | ------------------ | ----------------------------- |
86
+ | `owner` | — | Publisher address |
87
+ | `stream` | — | Stream name |
88
+ | `gateway` | `https://bzz.limo` | Swarm gateway or Bee node URL |
89
+ | `theme` | — | Partial `Theme` |
90
+
91
+ `player.playing`, `player.play()`, `player.stop()`, `player.destroy()`
92
+
93
+ `target` is an element or a CSS selector. `destroy()` stops and removes the component — call it when your framework unmounts.
94
+
95
+ ## How it works
96
+
97
+ The publisher records the microphone as Opus segments. Each segment is uploaded with `POST /bytes`, its root chunk is wrapped in a single-owner chunk, and that chunk is uploaded with `POST /soc`. Its identifier is the feed identifier of the next index, so this upload is also the feed update. The feed topic is the hash of the stream name, so owner + stream name locate the stream.
98
+
99
+ The player polls `/feeds/{owner}/{topic}` for the latest index, fetches each new segment's single-owner chunk with `GET /chunks/{address}`, downloads the wrapped segment with `GET /bytes/{root}`, and plays them back to back.
100
+
101
+ ## Examples
102
+
103
+ ```sh
104
+ npm install
105
+ npm run dev
106
+ ```
107
+
108
+ Open `/publisher.html`, go live, then open the listener link it shows.
109
+
110
+ `npm run publish:examples` builds both pages as standalone files, uploads them with [etherchunk](https://github.com/Cafe137/etherchunk), and prints their links.
@@ -0,0 +1,16 @@
1
+ export interface Chunk {
2
+ address: Uint8Array;
3
+ span: number;
4
+ data: Uint8Array;
5
+ }
6
+ export declare function chunkAddress(data: Uint8Array): Uint8Array;
7
+ export declare function rootChunk(data: Uint8Array): Chunk;
8
+ export declare function topic(stream: string): Uint8Array;
9
+ export declare function feedIdentifier(topic: Uint8Array, index: number): Uint8Array;
10
+ export declare function socAddress(identifier: Uint8Array, owner: string): Uint8Array;
11
+ export declare class Signer {
12
+ readonly privateKey: Uint8Array;
13
+ readonly owner: string;
14
+ constructor(privateKey?: string);
15
+ sign(identifier: Uint8Array, chunk: Chunk): Uint8Array;
16
+ }
package/dist/chunk.js ADDED
@@ -0,0 +1,69 @@
1
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
2
+ import { keccak_256 } from '@noble/hashes/sha3.js';
3
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
4
+ const CHUNK_SIZE = 4096;
5
+ const REFS_PER_CHUNK = 128;
6
+ function uint64(value, littleEndian) {
7
+ const bytes = new Uint8Array(8);
8
+ new DataView(bytes.buffer).setBigUint64(0, BigInt(value), littleEndian);
9
+ return bytes;
10
+ }
11
+ function bmt(payload) {
12
+ let level = new Uint8Array(CHUNK_SIZE);
13
+ level.set(payload);
14
+ while (level.length > 32) {
15
+ const parent = new Uint8Array(level.length / 2);
16
+ for (let i = 0; i < parent.length; i += 32)
17
+ parent.set(keccak_256(level.subarray(i * 2, i * 2 + 64)), i);
18
+ level = parent;
19
+ }
20
+ return level;
21
+ }
22
+ export function chunkAddress(data) {
23
+ return keccak_256(concatBytes(data.subarray(0, 8), bmt(data.subarray(8))));
24
+ }
25
+ function makeChunk(payload, span) {
26
+ const data = concatBytes(uint64(span, true), payload);
27
+ return { address: chunkAddress(data), span, data };
28
+ }
29
+ export function rootChunk(data) {
30
+ let level = [];
31
+ for (let i = 0; i < data.length; i += CHUNK_SIZE) {
32
+ const payload = data.subarray(i, i + CHUNK_SIZE);
33
+ level.push(makeChunk(payload, payload.length));
34
+ }
35
+ while (level.length > 1) {
36
+ const parents = [];
37
+ for (let i = 0; i < level.length; i += REFS_PER_CHUNK) {
38
+ const group = level.slice(i, i + REFS_PER_CHUNK);
39
+ const span = group.reduce((sum, chunk) => sum + chunk.span, 0);
40
+ parents.push(group.length === 1 ? group[0] : makeChunk(concatBytes(...group.map(chunk => chunk.address)), span));
41
+ }
42
+ level = parents;
43
+ }
44
+ return level[0];
45
+ }
46
+ export function topic(stream) {
47
+ return keccak_256(utf8ToBytes(stream));
48
+ }
49
+ export function feedIdentifier(topic, index) {
50
+ return keccak_256(concatBytes(topic, uint64(index, false)));
51
+ }
52
+ export function socAddress(identifier, owner) {
53
+ return keccak_256(concatBytes(identifier, hexToBytes(owner)));
54
+ }
55
+ export class Signer {
56
+ privateKey;
57
+ owner;
58
+ constructor(privateKey) {
59
+ this.privateKey = privateKey ? hexToBytes(privateKey.replace(/^0x/, '')) : secp256k1.utils.randomSecretKey();
60
+ const publicKey = secp256k1.getPublicKey(this.privateKey, false);
61
+ this.owner = bytesToHex(keccak_256(publicKey.subarray(1)).subarray(12));
62
+ }
63
+ sign(identifier, chunk) {
64
+ const digest = keccak_256(concatBytes(identifier, chunk.address));
65
+ const message = keccak_256(concatBytes(utf8ToBytes('\x19Ethereum Signed Message:\n32'), digest));
66
+ const signature = secp256k1.sign(message, this.privateKey, { prehash: false, format: 'recovered' });
67
+ return concatBytes(signature.subarray(1), new Uint8Array([27 + signature[0]]));
68
+ }
69
+ }
@@ -0,0 +1,3 @@
1
+ export { Player, type PlayerOptions } from './player.js';
2
+ export { Publisher, type PublisherOptions } from './publisher.js';
3
+ export { defaultTheme, type Target, type Theme } from './widget.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Player } from './player.js';
2
+ export { Publisher } from './publisher.js';
3
+ export { defaultTheme } from './widget.js';
@@ -0,0 +1,20 @@
1
+ import { Target, Theme } from './widget.js';
2
+ export interface PlayerOptions {
3
+ owner: string;
4
+ stream: string;
5
+ gateway?: string;
6
+ theme?: Partial<Theme>;
7
+ }
8
+ export declare class Player {
9
+ private readonly owner;
10
+ private readonly swarm;
11
+ private readonly topic;
12
+ private readonly widget;
13
+ private context?;
14
+ constructor(target: Target, options: PlayerOptions);
15
+ get playing(): boolean;
16
+ play(): void;
17
+ stop(): void;
18
+ destroy(): void;
19
+ private listen;
20
+ }
package/dist/player.js ADDED
@@ -0,0 +1,70 @@
1
+ import { topic } from './chunk.js';
2
+ import { Swarm } from './swarm.js';
3
+ import { Widget } from './widget.js';
4
+ const POLL_MS = 1000;
5
+ const BUFFER_SEGMENTS = 3;
6
+ const MAX_LAG = 8;
7
+ export class Player {
8
+ owner;
9
+ swarm;
10
+ topic;
11
+ widget;
12
+ context;
13
+ constructor(target, options) {
14
+ this.owner = options.owner.replace(/^0x/, '').toLowerCase();
15
+ this.swarm = new Swarm(options.gateway);
16
+ this.topic = topic(options.stream);
17
+ this.widget = new Widget(target, options.stream, options.theme, () => (this.playing ? this.stop() : this.play()));
18
+ this.widget.update(false, 'Tap to listen', 'Play');
19
+ }
20
+ get playing() {
21
+ return this.context !== undefined;
22
+ }
23
+ play() {
24
+ if (this.context)
25
+ return;
26
+ this.context = new AudioContext();
27
+ this.widget.update(true, 'Tuning in…', 'Stop');
28
+ this.listen(this.context);
29
+ }
30
+ stop() {
31
+ this.context?.close();
32
+ this.context = undefined;
33
+ this.widget.visualize();
34
+ this.widget.update(false, 'Tap to listen', 'Play');
35
+ }
36
+ destroy() {
37
+ this.stop();
38
+ this.widget.destroy();
39
+ }
40
+ async listen(context) {
41
+ const analyser = context.createAnalyser();
42
+ analyser.fftSize = 128;
43
+ analyser.connect(context.destination);
44
+ this.widget.visualize(analyser);
45
+ let next;
46
+ let time = 0;
47
+ while (context === this.context) {
48
+ try {
49
+ const latest = await this.swarm.latest(this.owner, this.topic);
50
+ if (next === undefined || latest - next > MAX_LAG)
51
+ next = Math.max(0, latest - BUFFER_SEGMENTS);
52
+ for (; next <= latest; next++) {
53
+ const source = context.createBufferSource();
54
+ source.buffer = await context.decodeAudioData(await this.swarm.segment(this.owner, this.topic, next));
55
+ source.connect(analyser);
56
+ time = Math.max(time, context.currentTime + 0.1);
57
+ source.start(time);
58
+ time += source.buffer.duration;
59
+ }
60
+ if (context === this.context)
61
+ this.widget.update(true, 'Live', 'Stop');
62
+ }
63
+ catch {
64
+ if (context === this.context)
65
+ this.widget.update(true, 'Waiting for the stream…', 'Stop');
66
+ }
67
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,31 @@
1
+ import { Target, Theme } from './widget.js';
2
+ export interface PublisherOptions {
3
+ stream: string;
4
+ privateKey?: string;
5
+ stamp?: string;
6
+ gateway?: string;
7
+ segmentSeconds?: number;
8
+ bitrate?: number;
9
+ theme?: Partial<Theme>;
10
+ }
11
+ export declare class Publisher {
12
+ private readonly options;
13
+ readonly owner: string;
14
+ private readonly signer;
15
+ private readonly swarm;
16
+ private readonly topic;
17
+ private readonly widget;
18
+ private context?;
19
+ private media?;
20
+ private recorder?;
21
+ private timer?;
22
+ private index;
23
+ private active;
24
+ constructor(target: Target, options: PublisherOptions);
25
+ get live(): boolean;
26
+ start(): Promise<void>;
27
+ stop(): void;
28
+ destroy(): void;
29
+ private record;
30
+ private publish;
31
+ }
@@ -0,0 +1,95 @@
1
+ import { Signer, topic } from './chunk.js';
2
+ import { Swarm } from './swarm.js';
3
+ import { Widget } from './widget.js';
4
+ const MIME_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4;codecs=opus'];
5
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
6
+ export class Publisher {
7
+ options;
8
+ owner;
9
+ signer;
10
+ swarm;
11
+ topic;
12
+ widget;
13
+ context;
14
+ media;
15
+ recorder;
16
+ timer;
17
+ index = 0;
18
+ active = false;
19
+ constructor(target, options) {
20
+ this.options = options;
21
+ this.signer = new Signer(options.privateKey);
22
+ this.owner = this.signer.owner;
23
+ this.swarm = new Swarm(options.gateway, options.stamp);
24
+ this.topic = topic(options.stream);
25
+ this.widget = new Widget(target, options.stream, options.theme, () => (this.live ? this.stop() : this.start()));
26
+ this.widget.update(false, 'Off air', 'Go live');
27
+ }
28
+ get live() {
29
+ return this.active;
30
+ }
31
+ async start() {
32
+ if (this.active)
33
+ return;
34
+ this.active = true;
35
+ this.widget.update(true, 'Going live…', 'Stop');
36
+ try {
37
+ const media = await navigator.mediaDevices.getUserMedia({ audio: true });
38
+ this.index = await this.swarm.latest(this.owner, this.topic).then(index => index + 1, () => 0);
39
+ if (!this.active)
40
+ return media.getTracks().forEach(track => track.stop());
41
+ this.media = media;
42
+ this.context = new AudioContext();
43
+ const analyser = this.context.createAnalyser();
44
+ analyser.fftSize = 128;
45
+ this.context.createMediaStreamSource(media).connect(analyser);
46
+ this.widget.visualize(analyser);
47
+ this.record(media);
48
+ this.widget.update(true, 'On air', 'Stop');
49
+ }
50
+ catch (error) {
51
+ this.stop();
52
+ this.widget.update(false, describe(error), 'Go live');
53
+ }
54
+ }
55
+ stop() {
56
+ this.active = false;
57
+ clearTimeout(this.timer);
58
+ this.recorder?.stop();
59
+ this.media?.getTracks().forEach(track => track.stop());
60
+ this.context?.close();
61
+ this.recorder = this.media = this.context = undefined;
62
+ this.widget.visualize();
63
+ this.widget.update(false, 'Off air', 'Go live');
64
+ }
65
+ destroy() {
66
+ this.stop();
67
+ this.widget.destroy();
68
+ }
69
+ record(media) {
70
+ const mimeType = MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type));
71
+ if (!mimeType)
72
+ throw new Error('This browser cannot record Opus audio');
73
+ this.recorder = new MediaRecorder(media, { mimeType, audioBitsPerSecond: this.options.bitrate ?? 64_000 });
74
+ this.recorder.ondataavailable = event => this.publish(event.data);
75
+ this.recorder.start();
76
+ this.timer = setTimeout(() => {
77
+ this.recorder?.stop();
78
+ this.record(media);
79
+ }, (this.options.segmentSeconds ?? 2) * 1000);
80
+ }
81
+ async publish(blob) {
82
+ if (!blob.size)
83
+ return;
84
+ const index = this.index++;
85
+ try {
86
+ await this.swarm.publish(this.signer, this.topic, index, new Uint8Array(await blob.arrayBuffer()));
87
+ if (this.active)
88
+ this.widget.update(true, `On air · segment ${index}`, 'Stop');
89
+ }
90
+ catch (error) {
91
+ if (this.active)
92
+ this.widget.update(true, `Upload failed: ${describe(error)}`, 'Stop');
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,12 @@
1
+ import { Signer } from './chunk.js';
2
+ export declare const DEFAULT_GATEWAY = "https://bzz.limo";
3
+ export declare class Swarm {
4
+ private readonly gateway;
5
+ private readonly stamp?;
6
+ constructor(gateway?: string, stamp?: string | undefined);
7
+ publish(signer: Signer, topic: Uint8Array, index: number, data: Uint8Array): Promise<void>;
8
+ latest(owner: string, topic: Uint8Array): Promise<number>;
9
+ segment(owner: string, topic: Uint8Array, index: number): Promise<ArrayBuffer>;
10
+ private post;
11
+ private request;
12
+ }
package/dist/swarm.js ADDED
@@ -0,0 +1,42 @@
1
+ import { bytesToHex } from '@noble/hashes/utils.js';
2
+ import { chunkAddress, feedIdentifier, rootChunk, socAddress } from './chunk.js';
3
+ export const DEFAULT_GATEWAY = 'https://bzz.limo';
4
+ export class Swarm {
5
+ gateway;
6
+ stamp;
7
+ constructor(gateway = DEFAULT_GATEWAY, stamp) {
8
+ this.gateway = gateway;
9
+ this.stamp = stamp;
10
+ }
11
+ async publish(signer, topic, index, data) {
12
+ await this.post('/bytes', data);
13
+ const chunk = rootChunk(data);
14
+ const identifier = feedIdentifier(topic, index);
15
+ const signature = signer.sign(identifier, chunk);
16
+ await this.post(`/soc/${signer.owner}/${bytesToHex(identifier)}?sig=${bytesToHex(signature)}`, chunk.data);
17
+ }
18
+ async latest(owner, topic) {
19
+ const response = await this.request(`/feeds/${owner}/${bytesToHex(topic)}`, { method: 'HEAD', cache: 'no-store' });
20
+ const index = parseInt(response.headers.get('swarm-feed-index') ?? '', 16);
21
+ if (Number.isNaN(index))
22
+ throw new Error('Feed index is not readable');
23
+ return index;
24
+ }
25
+ async segment(owner, topic, index) {
26
+ const soc = await this.request(`/chunks/${bytesToHex(socAddress(feedIdentifier(topic, index), owner))}`);
27
+ const root = chunkAddress(new Uint8Array(await soc.arrayBuffer()).subarray(97));
28
+ return (await this.request(`/bytes/${bytesToHex(root)}`)).arrayBuffer();
29
+ }
30
+ async post(path, body) {
31
+ const headers = { 'content-type': 'application/octet-stream', 'swarm-redundancy-level': '0' };
32
+ if (this.stamp)
33
+ headers['swarm-postage-batch-id'] = this.stamp;
34
+ return this.request(path, { method: 'POST', headers, body: body });
35
+ }
36
+ async request(path, init) {
37
+ const response = await fetch(this.gateway + path, init);
38
+ if (!response.ok)
39
+ throw new Error(`Swarm responded ${response.status} to ${path.split('?')[0]}`);
40
+ return response;
41
+ }
42
+ }
@@ -0,0 +1,22 @@
1
+ export interface Theme {
2
+ background: string;
3
+ foreground: string;
4
+ muted: string;
5
+ accent: string;
6
+ font: string;
7
+ radius: string;
8
+ }
9
+ export declare const defaultTheme: Theme;
10
+ export type Target = HTMLElement | string;
11
+ export declare class Widget {
12
+ private readonly host;
13
+ private readonly root;
14
+ private readonly button;
15
+ private readonly status;
16
+ private readonly bars;
17
+ private frame;
18
+ constructor(target: Target, title: string, theme: Partial<Theme> | undefined, onToggle: () => void);
19
+ update(active: boolean, status: string, label: string): void;
20
+ visualize(analyser?: AnalyserNode): void;
21
+ destroy(): void;
22
+ }
package/dist/widget.js ADDED
@@ -0,0 +1,75 @@
1
+ export const defaultTheme = {
2
+ background: '#16161d',
3
+ foreground: '#f4f4f6',
4
+ muted: '#8b8b99',
5
+ accent: '#ff5c39',
6
+ font: 'system-ui, -apple-system, "Segoe UI", sans-serif',
7
+ radius: '16px',
8
+ };
9
+ const BARS = 24;
10
+ const STYLE = `
11
+ :host { display: block }
12
+ .radio {
13
+ display: flex; align-items: center; gap: 16px; padding: 16px; box-sizing: border-box;
14
+ background: var(--background); color: var(--foreground); font-family: var(--font); border-radius: var(--radius);
15
+ }
16
+ button {
17
+ flex: none; width: 48px; height: 48px; border: none; border-radius: 50%; cursor: pointer;
18
+ background: var(--accent); display: grid; place-items: center; transition: transform .15s;
19
+ }
20
+ button:hover { transform: scale(1.06) }
21
+ button::after { content: ''; width: 16px; height: 16px; background: var(--background); clip-path: polygon(20% 0, 100% 50%, 20% 100%) }
22
+ .active button::after { clip-path: inset(10% round 2px) }
23
+ .text { flex: 1; min-width: 0 }
24
+ .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
25
+ .status { margin-top: 2px; font-size: .85em; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
26
+ .active .status::before { content: '● '; color: var(--accent) }
27
+ .meter { flex: none; display: flex; align-items: center; gap: 2px; height: 32px }
28
+ .meter span { width: 3px; height: 8%; border-radius: 2px; background: var(--accent); opacity: .85 }
29
+ `;
30
+ export class Widget {
31
+ host;
32
+ root;
33
+ button;
34
+ status;
35
+ bars;
36
+ frame = 0;
37
+ constructor(target, title, theme = {}, onToggle) {
38
+ const parent = typeof target === 'string' ? document.querySelector(target) : target;
39
+ if (!parent)
40
+ throw new Error(`Element not found: ${target}`);
41
+ this.host = parent.appendChild(document.createElement('div'));
42
+ for (const [key, value] of Object.entries({ ...defaultTheme, ...theme }))
43
+ this.host.style.setProperty(`--${key}`, value);
44
+ const shadow = this.host.attachShadow({ mode: 'open' });
45
+ shadow.innerHTML = `<style>${STYLE}</style>
46
+ <div class="radio"><button></button><div class="text"><div class="title"></div><div class="status"></div></div><div class="meter"></div></div>`;
47
+ this.root = shadow.querySelector('.radio');
48
+ this.status = shadow.querySelector('.status');
49
+ shadow.querySelector('.title').textContent = title;
50
+ this.button = shadow.querySelector('button');
51
+ this.button.onclick = onToggle;
52
+ const meter = shadow.querySelector('.meter');
53
+ this.bars = Array.from({ length: BARS }, () => meter.appendChild(document.createElement('span')));
54
+ }
55
+ update(active, status, label) {
56
+ this.root.classList.toggle('active', active);
57
+ this.status.textContent = status;
58
+ this.button.ariaLabel = label;
59
+ }
60
+ visualize(analyser) {
61
+ cancelAnimationFrame(this.frame);
62
+ const levels = new Uint8Array(analyser?.frequencyBinCount ?? 0);
63
+ const draw = () => {
64
+ analyser?.getByteFrequencyData(levels);
65
+ this.bars.forEach((bar, i) => (bar.style.height = `${8 + ((levels[i * 2] ?? 0) / 255) * 92}%`));
66
+ if (analyser)
67
+ this.frame = requestAnimationFrame(draw);
68
+ };
69
+ draw();
70
+ }
71
+ destroy() {
72
+ cancelAnimationFrame(this.frame);
73
+ this.host.remove();
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "web3-radio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Live audio streaming on Swarm. Embeddable publisher and player.",
5
+ "keywords": [
6
+ "swarm",
7
+ "ethswarm",
8
+ "web3",
9
+ "radio",
10
+ "audio",
11
+ "streaming",
12
+ "opus"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "sideEffects": false,
28
+ "scripts": {
29
+ "dev": "vite examples",
30
+ "build": "tsc",
31
+ "prepublishOnly": "npm run build",
32
+ "publish:examples": "sh scripts/publish-examples.sh"
33
+ },
34
+ "dependencies": {
35
+ "@noble/curves": "^2.4.0",
36
+ "@noble/hashes": "^2.4.0"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^7.0.2",
40
+ "vite": "^8.3.3",
41
+ "vite-plugin-singlefile": "^2.3.3"
42
+ }
43
+ }