@motionscript/audio 0.1.0-alpha.5 → 0.1.0-alpha.6

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.
@@ -1,20 +1,17 @@
1
1
  import {
2
- Node2D,
3
- node,
4
- command,
2
+ Types,
3
+ component2d,
5
4
  easeInOut,
6
5
  fillOps,
7
- property,
8
- type AssetScope,
9
- type CommandArgs,
10
6
  type Fill,
11
7
  type FillResolved,
12
- type Node2DProps,
13
- type RenderContext2D,
8
+ type NodeOf,
9
+ type RenderContext,
10
+ // Named so the emitted declaration reaches these through the sdk, not core.
11
+ type Node2D as _Node2D,
12
+ type Node2DProps as _Node2DProps,
14
13
  } from "@motionscript/sdk"
15
14
 
16
- import { declarePaints } from "@motionscript/sdk/component"
17
- import type { Seekable } from "@motionscript/sdk/component"
18
15
  import { EMPTY_ENVELOPE, sampleEnvelope, type AudioEnvelope } from "./envelope"
19
16
  import {
20
17
  clamp01,
@@ -25,33 +22,9 @@ import {
25
22
  type WaveformAlignment,
26
23
  } from "./shared"
27
24
 
28
- export interface FullWaveformProps extends Node2DProps {
29
- /** The baked recording. See {@link AudioEnvelope} for why it is baked. */
30
- envelope: AudioEnvelope
31
- /** How many columns the whole file is drawn as. */
32
- bars: number
33
- /** Space between bars, as a fraction of the pitch. See {@link layOutBars}. */
34
- gap: number
35
- /** Corner radius of a bar, capped at half its width. */
36
- radius: number
37
- /** How much of the file reads as played, `0`–`1`. Tweenable. */
38
- progress: number
39
- /** Drive {@link progress} from the node's own clock instead of the prop. */
40
- follow: boolean
41
- /** Multiplies every magnitude — a quiet track pulled up to fill its box. */
42
- gain: number
43
- /** How much of the box a silent bar still occupies, `0`–`1`. */
44
- floor: number
45
- align: WaveformAlignment
46
- /** Colour each voice's turns rather than splitting on played / not played. */
47
- byVoice: boolean
48
- /** A colour per voice index. Short or empty falls back to the palette. */
49
- voiceColors: string[]
50
- /** The bars ahead of the playhead. */
51
- fill: Fill
52
- /** …and the bars behind it. */
53
- playedFill: Fill
54
- }
25
+ /** A paint entry as the waveforms declare theirs: resolved and tweened like a fill, its kind read off the default. */
26
+ const paint = (initial: string) =>
27
+ Types.custom<Fill, FillResolved[]>(initial, { resolve: fillOps.resolve, lerp: fillOps.lerp })
55
28
 
56
29
  /**
57
30
  * A **waveform overview**: the whole recording's shape, drawn at once.
@@ -64,7 +37,7 @@ export interface FullWaveformProps extends Node2DProps {
64
37
  *
65
38
  * ## Progress is authored, not assumed
66
39
  *
67
- * {@link follow} is off by default, and that default is the considered one. A
40
+ * `follow` is off by default, and that default is the considered one. A
68
41
  * scene has no idea where the recording it is drawing sits on the video's audio
69
42
  * lane — a clip is placed, trimmed and sped up out there, and none of that is
70
43
  * visible from in here. So the honest default is that the fill is *animation*:
@@ -76,142 +49,140 @@ export interface FullWaveformProps extends Node2DProps {
76
49
  *
77
50
  * ## Voices instead of progress
78
51
  *
79
- * With {@link byVoice} on, the played/ahead split is replaced by one colour per
52
+ * With `byVoice` on, the played/ahead split is replaced by one colour per
80
53
  * speaking voice, at a size you can read. For a two-hander it is a map of the conversation: who has the
81
54
  * floor, where the turns are, how long each one ran. It replaces the progress
82
55
  * colours rather than combining with them, because a bar cannot mean two things
83
56
  * at once and "played, and also Grace" is a legend nobody can hold in their
84
57
  * head.
85
58
  */
86
- @node({
59
+ export const FullWaveform = component2d({
87
60
  key: "fullWaveform",
88
- parentKey: "node",
89
- forkable: true,
90
- layout: {
91
- children: "freeform",
92
- defaultWidthMode: "fixed",
93
- defaultHeightMode: "fixed",
94
- acceptsChildren: true,
61
+ state: {
62
+ /** The baked recording. See {@link AudioEnvelope} for why it is baked. */
63
+ envelope: Types.custom<AudioEnvelope>(EMPTY_ENVELOPE),
64
+ /** How many columns the whole file is drawn as. */
65
+ bars: 120,
66
+ /** Space between bars, as a fraction of the pitch. See {@link layOutBars}. */
67
+ gap: 0.3,
68
+ /** Corner radius of a bar, capped at half its width. */
69
+ radius: 2,
70
+ /** How much of the file reads as played, `0`–`1`. Tweenable. */
71
+ progress: 0,
72
+ /** Drive `progress` from the node's own clock instead of the prop. */
73
+ follow: false,
74
+ /** Multiplies every magnitude — a quiet track pulled up to fill its box. */
75
+ gain: 1,
76
+ /** How much of the box a silent bar still occupies, `0`–`1`. */
77
+ floor: 0.015,
78
+ align: Types.custom<WaveformAlignment>("center"),
79
+ /** Colour each voice's turns rather than splitting on played / not played. */
80
+ byVoice: false,
81
+ /** A colour per voice index. Short or empty falls back to the palette. */
82
+ voiceColors: Types.custom<string[]>([]),
83
+ /** The bars ahead of the playhead. */
84
+ fill: paint("#5b5b7a"),
85
+ /** …and the bars behind it. */
86
+ playedFill: paint("#6366f1"),
87
+ },
88
+ // Nothing declares a component's paints but itself: `fill` and `playedFill` both.
89
+ load: (loader, state) => {
90
+ loader.fill(state.fill)
91
+ loader.fill(state.playedFill)
92
+ },
93
+ commands: {
94
+ /**
95
+ * Fill the waveform in, left to right — the node's one command, since
96
+ * everything else about it is expressible as an ordinary `to`. Eased in-out
97
+ * unless it is given an easing of its own.
98
+ */
99
+ sweep: (timeline, state) => {
100
+ const ease = timeline.easing ? null : easeInOut()
101
+ const from = state.progress
102
+ return timeline.animate((t) => ({ progress: from + (1 - from) * (ease ? ease(t) : t) }))
103
+ },
95
104
  },
96
- seed: {
97
- width: 900,
98
- height: 160,
105
+ render: (ctx, state) => {
106
+ drawBars(ctx, state)
107
+ ctx.children()
99
108
  },
100
109
  })
101
- export class FullWaveform extends Node2D<FullWaveformProps> {
102
- @property({ default: EMPTY_ENVELOPE }) declare envelope: AudioEnvelope
103
- @property({ default: 120 }) declare bars: number
104
- @property({ default: 0.3 }) declare gap: number
105
- @property({ default: 2 }) declare radius: number
106
- @property({ default: 0 }) declare progress: number
107
- @property({ default: false }) declare follow: boolean
108
- @property({ default: 1 }) declare gain: number
109
- @property({ default: 0.015 }) declare floor: number
110
- @property({ default: "center" }) declare align: WaveformAlignment
111
- @property({ default: false }) declare byVoice: boolean
112
- @property({ default: [] }) declare voiceColors: string[]
113
-
114
- @property({
115
- default: "#5b5b7a",
116
- mapper: fillOps.resolve,
117
- tween: fillOps.lerp,
118
- })
119
- declare fill: Fill
120
110
 
121
- @property({
122
- default: "#6366f1",
123
- mapper: fillOps.resolve,
124
- tween: fillOps.lerp,
125
- })
126
- declare playedFill: Fill
127
-
128
- /**
129
- * Fill the waveform in, left to right — the node's one command, since
130
- * everything else about it is expressible as an ordinary `to`.
131
- */
132
- @command()
133
- sweep(args: CommandArgs<Record<string, never>> & { duration: number }): Seekable {
134
- return this.to({
135
- data: { progress: 1 } as Partial<FullWaveformProps>,
136
- duration: args.duration,
137
- easing: args.easing ?? easeInOut(),
138
- })
139
- }
140
-
141
- /**
142
- * This node extends `Node2D` rather than `ShapeNode`, so nothing declares the
143
- * paint props it carries — see {@link declarePaints}. `playedFill` is named
144
- * past the conventional slots, so it is passed explicitly.
145
- */
146
- override declareAssets(assets: AssetScope): void {
147
- super.declareAssets(assets)
148
- declarePaints(this, assets, { fills: [this.playedFill as FillResolved[]] })
149
- }
111
+ export type FullWaveform = NodeOf<typeof FullWaveform>
112
+ export type FullWaveformProps = NonNullable<Parameters<typeof FullWaveform>[0]>
150
113
 
151
- /** Where the fill has reached, from whichever source is in charge. */
152
- private get played(): number {
153
- if (!this.follow) return clamp01(this.progress)
154
- const length = this.envelope.duration
155
- if (length <= 0) return 0
156
- return clamp01(this.time.elapsed / length)
157
- }
114
+ interface FullState {
115
+ readonly envelope: AudioEnvelope
116
+ readonly bars: number
117
+ readonly gap: number
118
+ readonly radius: number
119
+ readonly progress: number
120
+ readonly follow: boolean
121
+ readonly gain: number
122
+ readonly floor: number
123
+ readonly align: WaveformAlignment
124
+ readonly byVoice: boolean
125
+ readonly voiceColors: string[]
126
+ readonly fill: FillResolved[]
127
+ readonly playedFill: FillResolved[]
128
+ }
158
129
 
159
- protected renderSelf(ctx: RenderContext2D): void {
160
- const rect = this.layoutBounds
161
- const width = rect?.width ?? 0
162
- const height = rect?.height ?? 0
163
- if (width <= 0 || height <= 0) return
130
+ function drawBars(ctx: RenderContext, s: FullState): void {
131
+ const { width, height } = ctx.size
132
+ if (width <= 0 || height <= 0) return
164
133
 
165
- const count = Math.max(1, Math.round(this.bars))
166
- const sampled = sampleEnvelope(
167
- this.envelope,
168
- 0,
169
- this.envelope.duration,
170
- count
171
- )
172
- const boxes = layOutBars(sampled, {
173
- width,
174
- height,
175
- gap: this.gap,
176
- align: this.align,
177
- gain: this.gain,
178
- floor: this.floor,
179
- })
180
- if (boxes.length === 0) return
134
+ const count = Math.max(1, Math.round(s.bars))
135
+ const sampled = sampleEnvelope(s.envelope, 0, s.envelope.duration, count)
136
+ const boxes = layOutBars(sampled, {
137
+ width,
138
+ height,
139
+ gap: s.gap,
140
+ align: s.align,
141
+ gain: s.gain,
142
+ floor: s.floor,
143
+ })
144
+ if (boxes.length === 0) return
181
145
 
182
- // One draw per colour rather than per bar — see {@link paintBars}. The key
183
- // is a string because it has to be comparable; the paint it stands for is
184
- // resolved after the grouping.
185
- const edge = this.played * boxes.length
186
- const groups = groupByPaint(boxes, (i) =>
187
- this.byVoice
188
- ? `voice:${sampled[i].speaker}`
189
- : i < edge
190
- ? "played"
191
- : "ahead"
192
- )
146
+ // One draw per colour rather than per bar — see {@link paintBars}. The key
147
+ // is a string because it has to be comparable; the paint it stands for is
148
+ // resolved after the grouping.
149
+ const edge = played(ctx, s) * boxes.length
150
+ const groups = groupByPaint(boxes, (i) =>
151
+ s.byVoice
152
+ ? `voice:${sampled[i].speaker}`
153
+ : i < edge
154
+ ? "played"
155
+ : "ahead"
156
+ )
193
157
 
194
- for (const [key, run] of groups) {
195
- const graphics = paintBars(run, this.paintFor(key), this.radius)
196
- if (graphics) ctx.draw(graphics)
197
- }
158
+ for (const [key, run] of groups) {
159
+ const graphics = paintBars(run, paintFor(s, key), s.radius)
160
+ if (graphics) ctx.draw(graphics)
198
161
  }
162
+ }
163
+
164
+ /** Where the fill has reached, from whichever source is in charge. */
165
+ function played(ctx: RenderContext, s: FullState): number {
166
+ if (!s.follow) return clamp01(s.progress)
167
+ const length = s.envelope.duration
168
+ if (length <= 0) return 0
169
+ return clamp01(ctx.time.elapsed / length)
170
+ }
199
171
 
200
- /**
201
- * The paint a group takes.
202
- *
203
- * A voice's colour is a **colour string**, never one of the node's two fill
204
- * props, and that is deliberate: a voice is identified by hue, so a gradient
205
- * or an image there would defeat the only thing the colour is for. It also
206
- * means these paints declare no assets, which is why {@link declareAssets}
207
- * only has to account for the two real fill props.
208
- */
209
- private paintFor(key: string): Fill {
210
- if (key === "played") return this.playedFill
211
- if (key === "ahead") return this.fill
172
+ /**
173
+ * The paint a group takes.
174
+ *
175
+ * A voice's colour is a **colour string**, never one of the node's two fill
176
+ * props, and that is deliberate: a voice is identified by hue, so a gradient
177
+ * or an image there would defeat the only thing the colour is for. It also
178
+ * means these paints declare no assets, which is why `load` only has to account
179
+ * for the two real fill props.
180
+ */
181
+ function paintFor(s: FullState, key: string): Fill {
182
+ if (key === "played") return s.playedFill
183
+ if (key === "ahead") return s.fill
212
184
 
213
- const voice = Number(key.slice("voice:".length))
214
- if (!Number.isFinite(voice) || voice < 0) return this.fill
215
- return this.voiceColors[voice] ?? voiceColor(voice)
216
- }
185
+ const voice = Number(key.slice("voice:".length))
186
+ if (!Number.isFinite(voice) || voice < 0) return s.fill
187
+ return s.voiceColors[voice] ?? voiceColor(voice)
217
188
  }
@@ -1,15 +1,16 @@
1
1
  import {
2
- Node2D,
3
- node,
2
+ Types,
3
+ component2d,
4
4
  fillOps,
5
- property,
6
- type AssetScope,
7
5
  type Fill,
8
- type Node2DProps,
9
- type RenderContext2D,
6
+ type FillResolved,
7
+ type NodeOf,
8
+ type RenderContext,
9
+ // Named so the emitted declaration reaches these through the sdk, not core.
10
+ type Node2D as _Node2D,
11
+ type Node2DProps as _Node2DProps,
10
12
  } from "@motionscript/sdk"
11
13
 
12
- import { declarePaints } from "@motionscript/sdk/component"
13
14
  import {
14
15
  EMPTY_ENVELOPE,
15
16
  forSpeaker,
@@ -28,38 +29,6 @@ import {
28
29
  type WaveformStyle,
29
30
  } from "./shared"
30
31
 
31
- export interface LiveWaveformProps extends Node2DProps {
32
- /** The baked recording. See {@link AudioEnvelope} for why it is baked. */
33
- envelope: AudioEnvelope
34
- /** How many bars the visualiser is made of. */
35
- bars: number
36
- /** Space between bars, as a fraction of the pitch. */
37
- gap: number
38
- radius: number
39
- /** How many seconds of audio are on screen at once. */
40
- window: number
41
- /** Seconds into the file the node's first frame shows. */
42
- offset: number
43
- /** Advance with the scene clock. Off freezes the picture at {@link offset}. */
44
- playing: boolean
45
- /** Multiplies every magnitude before it is clamped. */
46
- gain: number
47
- /** How much of the box a silent bar still occupies, `0`–`1`. */
48
- floor: number
49
- align: WaveformAlignment
50
- style: WaveformStyle
51
- /**
52
- * Show only this voice's activity, by index into the recording's own
53
- * numbering. `-1` — the default — is every voice.
54
- */
55
- speaker: number
56
- /** Colour each bar by whoever is speaking in it. */
57
- byVoice: boolean
58
- /** A colour per voice index. Short or empty falls back to the palette. */
59
- voiceColors: string[]
60
- fill: Fill
61
- }
62
-
63
32
  /**
64
33
  * A **live audio visualiser**: bars that move with the recording as it plays.
65
34
  *
@@ -80,7 +49,7 @@ export interface LiveWaveformProps extends Node2DProps {
80
49
  *
81
50
  * ## The speaker filter
82
51
  *
83
- * {@link speaker} narrows the node to one voice of a diarized recording, and it
52
+ * `speaker` narrows the node to one voice of a diarized recording, and it
84
53
  * is the reason this node knows about transcripts at all. Two visualisers beside
85
54
  * two headshots, each filtered to its own host, is a podcast edit that would
86
55
  * otherwise be done by hand for every turn — and because the filter *silences*
@@ -93,109 +62,125 @@ export interface LiveWaveformProps extends Node2DProps {
93
62
  * true, where falling back to the unfiltered mix would be a silent lie about
94
63
  * whose voice you were watching.
95
64
  */
96
- @node({
65
+ export const LiveWaveform = component2d({
97
66
  key: "liveWaveform",
98
- parentKey: "node",
99
- forkable: true,
100
- layout: {
101
- children: "freeform",
102
- defaultWidthMode: "fixed",
103
- defaultHeightMode: "fixed",
104
- acceptsChildren: true,
67
+ state: {
68
+ /** The baked recording. See {@link AudioEnvelope} for why it is baked. */
69
+ envelope: Types.custom<AudioEnvelope>(EMPTY_ENVELOPE),
70
+ /** How many bars the visualiser is made of. */
71
+ bars: 48,
72
+ /** Space between bars, as a fraction of the pitch. */
73
+ gap: 0.35,
74
+ radius: 4,
75
+ /** How many seconds of audio are on screen at once. */
76
+ window: 3,
77
+ /** Seconds into the file the node's first frame shows. */
78
+ offset: 0,
79
+ /** Advance with the scene clock. Off freezes the picture at `offset`. */
80
+ playing: true,
81
+ /** Multiplies every magnitude before it is clamped. */
82
+ gain: 1.4,
83
+ /** How much of the box a silent bar still occupies, `0`–`1`. */
84
+ floor: 0.04,
85
+ align: Types.custom<WaveformAlignment>("center"),
86
+ style: Types.custom<WaveformStyle>("bars"),
87
+ /**
88
+ * Show only this voice's activity, by index into the recording's own
89
+ * numbering. `-1` — the default — is every voice.
90
+ */
91
+ speaker: -1,
92
+ /** Colour each bar by whoever is speaking in it. */
93
+ byVoice: false,
94
+ /** A colour per voice index. Short or empty falls back to the palette. */
95
+ voiceColors: Types.custom<string[]>([]),
96
+ fill: Types.custom<Fill, FillResolved[]>("#6366f1", { resolve: fillOps.resolve, lerp: fillOps.lerp }),
105
97
  },
106
- seed: {
107
- width: 420,
108
- height: 220,
98
+ // Nothing declares a component's paints but itself.
99
+ load: (loader, state) => loader.fill(state.fill),
100
+ render: (ctx, state) => {
101
+ drawBars(ctx, state)
102
+ ctx.children()
109
103
  },
110
104
  })
111
- export class LiveWaveform extends Node2D<LiveWaveformProps> {
112
- @property({ default: EMPTY_ENVELOPE }) declare envelope: AudioEnvelope
113
- @property({ default: 48 }) declare bars: number
114
- @property({ default: 0.35 }) declare gap: number
115
- @property({ default: 4 }) declare radius: number
116
- @property({ default: 3 }) declare window: number
117
- @property({ default: 0 }) declare offset: number
118
- @property({ default: true }) declare playing: boolean
119
- @property({ default: 1.4 }) declare gain: number
120
- @property({ default: 0.04 }) declare floor: number
121
- @property({ default: "center" }) declare align: WaveformAlignment
122
- @property({ default: "bars" }) declare style: WaveformStyle
123
- @property({ default: -1 }) declare speaker: number
124
- @property({ default: false }) declare byVoice: boolean
125
- @property({ default: [] }) declare voiceColors: string[]
126
105
 
127
- @property({
128
- default: "#6366f1",
129
- mapper: fillOps.resolve,
130
- tween: fillOps.lerp,
131
- })
132
- declare fill: Fill
106
+ export type LiveWaveform = NodeOf<typeof LiveWaveform>
107
+ export type LiveWaveformProps = NonNullable<Parameters<typeof LiveWaveform>[0]>
133
108
 
134
- override declareAssets(assets: AssetScope): void {
135
- super.declareAssets(assets)
136
- declarePaints(this, assets)
137
- }
109
+ interface LiveState {
110
+ readonly envelope: AudioEnvelope
111
+ readonly bars: number
112
+ readonly gap: number
113
+ readonly radius: number
114
+ readonly window: number
115
+ readonly offset: number
116
+ readonly playing: boolean
117
+ readonly gain: number
118
+ readonly floor: number
119
+ readonly align: WaveformAlignment
120
+ readonly style: WaveformStyle
121
+ readonly speaker: number
122
+ readonly byVoice: boolean
123
+ readonly voiceColors: string[]
124
+ readonly fill: FillResolved[]
125
+ }
138
126
 
139
- /**
140
- * The second of the recording the visualiser is showing.
141
- *
142
- * Off the node's own clock rather than the scene's, which is what "from the
143
- * moment this node appeared" means — the same clock the engine's own
144
- * `Video` times its picture from, and the same one that makes a node dropped
145
- * halfway through a clip start its recording at its entrance rather than
146
- * partway in.
147
- */
148
- private get at(): number {
149
- return this.offset + (this.playing ? Math.max(0, this.time.elapsed) : 0)
150
- }
127
+ /**
128
+ * The second of the recording the visualiser is showing.
129
+ *
130
+ * Off the node's own clock rather than the scene's, which is what "from the
131
+ * moment this node appeared" means — the same clock the engine's own
132
+ * `Video` times its picture from, and the same one that makes a node dropped
133
+ * halfway through a clip start its recording at its entrance rather than
134
+ * partway in.
135
+ */
136
+ function at(ctx: RenderContext, s: LiveState): number {
137
+ return s.offset + (s.playing ? Math.max(0, ctx.time.elapsed) : 0)
138
+ }
151
139
 
152
- protected renderSelf(ctx: RenderContext2D): void {
153
- const rect = this.layoutBounds
154
- const width = rect?.width ?? 0
155
- const height = rect?.height ?? 0
156
- if (width <= 0 || height <= 0) return
140
+ function drawBars(ctx: RenderContext, s: LiveState): void {
141
+ const { width, height } = ctx.size
142
+ if (width <= 0 || height <= 0) return
157
143
 
158
- const count = Math.max(1, Math.round(this.bars))
159
- const span = Math.max(0.05, this.window)
160
- const at = this.at
161
- const voice = this.speaker < 0 ? null : Math.round(this.speaker)
144
+ const count = Math.max(1, Math.round(s.bars))
145
+ const span = Math.max(0.05, s.window)
146
+ const now = at(ctx, s)
147
+ const voice = s.speaker < 0 ? null : Math.round(s.speaker)
162
148
 
163
- // The window is centred on the playhead, so the bars run *toward* the
164
- // middle and away again — the arrangement every mixer's meter uses, and the
165
- // one that reads as sound arriving rather than as a strip scrolling past.
166
- const sampled = forSpeaker(
167
- sampleEnvelope(this.envelope, at - span / 2, at + span / 2, count),
168
- voice
169
- )
149
+ // The window is centred on the playhead, so the bars run *toward* the
150
+ // middle and away again — the arrangement every mixer's meter uses, and the
151
+ // one that reads as sound arriving rather than as a strip scrolling past.
152
+ const sampled = forSpeaker(
153
+ sampleEnvelope(s.envelope, now - span / 2, now + span / 2, count),
154
+ voice
155
+ )
170
156
 
171
- const shaped = this.style === "line" ? taper(sampled) : sampled
172
- const boxes = layOutBars(shaped, {
173
- width,
174
- height,
175
- gap: this.style === "blocks" ? 0.12 : this.gap,
176
- align: this.align,
177
- gain: this.gain,
178
- floor: this.floor,
179
- })
180
- if (boxes.length === 0) return
157
+ const shaped = s.style === "line" ? taper(sampled) : sampled
158
+ const boxes = layOutBars(shaped, {
159
+ width,
160
+ height,
161
+ gap: s.style === "blocks" ? 0.12 : s.gap,
162
+ align: s.align,
163
+ gain: s.gain,
164
+ floor: s.floor,
165
+ })
166
+ if (boxes.length === 0) return
181
167
 
182
- const groups = groupByPaint(boxes, (i) =>
183
- livePaintKey(shaped[i].speaker, this.byVoice, voice)
184
- )
168
+ const groups = groupByPaint(boxes, (i) =>
169
+ livePaintKey(shaped[i].speaker, s.byVoice, voice)
170
+ )
185
171
 
186
- for (const [key, run] of groups) {
187
- const graphics = paintBars(run, this.paintFor(key), this.radius)
188
- if (graphics) ctx.draw(graphics)
189
- }
172
+ for (const [key, run] of groups) {
173
+ const graphics = paintBars(run, paintFor(s, key), s.radius)
174
+ if (graphics) ctx.draw(graphics)
190
175
  }
176
+ }
191
177
 
192
- /** See {@link FullWaveform.paintFor} — a voice is a hue, never a gradient. */
193
- private paintFor(key: string): Fill {
194
- if (key === "all") return this.fill
195
- const voice = Number(key.slice("voice:".length))
196
- if (!Number.isFinite(voice) || voice < 0) return this.fill
197
- return this.voiceColors[voice] ?? voiceColor(voice)
198
- }
178
+ /** See `FullWaveform`'s paints — a voice is a hue, never a gradient. */
179
+ function paintFor(s: LiveState, key: string): Fill {
180
+ if (key === "all") return s.fill
181
+ const voice = Number(key.slice("voice:".length))
182
+ if (!Number.isFinite(voice) || voice < 0) return s.fill
183
+ return s.voiceColors[voice] ?? voiceColor(voice)
199
184
  }
200
185
 
201
186
  /**