@braccato/core 1.4.0 → 1.5.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
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
186
186
  browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
187
  pair snaps at the halfway point instead of flowing.
188
188
 
189
+ ### Letter wave (experimental)
190
+
191
+ On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
192
+ per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
193
+ a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
194
+ replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
195
+ it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
196
+ to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
197
+
198
+ A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
199
+ swells each letter with a transient scale at the crest.
200
+
201
+ ```css
202
+ .blyrics-container {
203
+ --blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
204
+ --blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
205
+ --blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
206
+ --blyrics-letter-wave-duration: 0.9s;
207
+ --blyrics-letter-wave-rise-easing: ease-in-out;
208
+ --blyrics-letter-wave-fall-easing: ease-out;
209
+ }
210
+ ```
211
+
212
+ The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
213
+ does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
214
+ reloads the lines when it changes, the way every build-time setting does.
215
+
189
216
  ### Class names
190
217
 
191
218
  These are published API rather than implementation. Renaming one costs a migration rather than a
package/dist/README.md CHANGED
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
186
186
  browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
187
  pair snaps at the halfway point instead of flowing.
188
188
 
189
+ ### Letter wave (experimental)
190
+
191
+ On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
192
+ per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
193
+ a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
194
+ replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
195
+ it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
196
+ to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
197
+
198
+ A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
199
+ swells each letter with a transient scale at the crest.
200
+
201
+ ```css
202
+ .blyrics-container {
203
+ --blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
204
+ --blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
205
+ --blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
206
+ --blyrics-letter-wave-duration: 0.9s;
207
+ --blyrics-letter-wave-rise-easing: ease-in-out;
208
+ --blyrics-letter-wave-fall-easing: ease-out;
209
+ }
210
+ ```
211
+
212
+ The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
213
+ does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
214
+ reloads the lines when it changes, the way every build-time setting does.
215
+
189
216
  ### Class names
190
217
 
191
218
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -2,6 +2,7 @@ export declare const LYRICS_WRAPPER_ID: "blyrics-wrapper";
2
2
  export declare const LYRICS_CLASS: "blyrics-container";
3
3
  export declare const LINE_CLASS: "blyrics--line";
4
4
  export declare const WORD_CLASS: "blyrics--word";
5
+ export declare const LETTER_CLASS: "blyrics--letter";
5
6
  export declare const FOOTER_CLASS: "blyrics-footer";
6
7
  export declare const CURRENT_LYRICS_CLASS: "blyrics--active";
7
8
  export declare const ANIMATING_CLASS: "blyrics--animating";
package/dist/constants.js CHANGED
@@ -5,6 +5,7 @@ export const LYRICS_WRAPPER_ID = "blyrics-wrapper";
5
5
  export const LYRICS_CLASS = "blyrics-container";
6
6
  export const LINE_CLASS = "blyrics--line";
7
7
  export const WORD_CLASS = "blyrics--word";
8
+ export const LETTER_CLASS = "blyrics--letter";
8
9
  export const FOOTER_CLASS = "blyrics-footer";
9
10
  // -- Playback state --------------------------------------------
10
11
  export const CURRENT_LYRICS_CLASS = "blyrics--active";
package/dist/engine.d.ts CHANGED
@@ -175,6 +175,14 @@ interface AnimationConfig {
175
175
  wobblePeakOffset: number;
176
176
  wobbleSettleOffset: number;
177
177
  };
178
+ letterWave: {
179
+ transform: string;
180
+ settle: string;
181
+ emphasisScale: string;
182
+ durationMs: number;
183
+ riseEasing: string;
184
+ fallEasing: string;
185
+ };
178
186
  instrumental: {
179
187
  fillFadeDurationMs: number;
180
188
  fillFadeEasing: string;
package/dist/engine.js CHANGED
@@ -755,9 +755,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
755
755
  offset: config.word.wobblePeakOffset,
756
756
  easing: config.word.wobblePeakEasing,
757
757
  },
758
- // The two offsets are read and clamped independently, so a theme is free to settle
759
- // before it peaks. animate() rejects offsets that go backwards, and that throw would
760
- // orphan the highlight animations above, which are not tracked until the end.
761
758
  {
762
759
  transform: config.word.wobbleSettle,
763
760
  offset: Math.max(config.word.wobblePeakOffset, config.word.wobbleSettleOffset),
@@ -770,8 +767,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
770
767
  fill: "forwards",
771
768
  };
772
769
  const wobbleStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
773
- // The wobble is a paint transform, so the highlight copy must carry it too or the active
774
- // sweep drifts off the word.
775
770
  for (const wordElement of [part.lyricElement, part.highlightElement]) {
776
771
  const animation = trackLyricAnimationTiming(engine, wordElement.animate(wobbleKeyframes, wobbleOptions), {
777
772
  appliedTimingOffsetMs,
@@ -780,6 +775,40 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
780
775
  animation.currentTime = wobbleStartMs;
781
776
  wobbleAnimations.push(animation);
782
777
  }
778
+ const letters = part.letterElements;
779
+ if (letters && letters.length > 0) {
780
+ const emphasise = part.lyricElement?.dataset.longWord === "true";
781
+ const emphasisPeak = emphasise ? ` scale(${config.letterWave.emphasisScale})` : "";
782
+ const emphasisRest = emphasise ? " scale(1)" : "";
783
+ const floatKeyframes = [
784
+ { transform: `translateY(0)${emphasisRest}`, easing: config.letterWave.riseEasing },
785
+ {
786
+ transform: `${config.letterWave.transform}${emphasisPeak}`,
787
+ offset: 0.4,
788
+ easing: config.letterWave.fallEasing,
789
+ },
790
+ { transform: `${config.letterWave.settle}${emphasisRest}` },
791
+ ];
792
+ const letterCount = letters.length;
793
+ const staggerMs = timedDurationMs > 0 ? timedDurationMs / 2.5 / letterCount : 0;
794
+ const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
795
+ const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
796
+ for (const set of [part.letterElements, part.highlightLetterElements]) {
797
+ set?.forEach((letterElement, index) => {
798
+ const options = {
799
+ duration: config.letterWave.durationMs,
800
+ delay: index * staggerMs,
801
+ fill: "forwards",
802
+ };
803
+ const animation = trackLyricAnimationTiming(engine, letterElement.animate(floatKeyframes, options), {
804
+ appliedTimingOffsetMs,
805
+ offsetMs: 0,
806
+ });
807
+ animation.currentTime = floatStartMs;
808
+ wobbleAnimations.push(animation);
809
+ });
810
+ }
811
+ }
783
812
  }
784
813
  part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
785
814
  }
@@ -1007,6 +1036,14 @@ function readAnimationConfig(engine, lyricsElement) {
1007
1036
  wobblePeakOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-peak-offset", 0.125),
1008
1037
  wobbleSettleOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-settle-offset", 0.75),
1009
1038
  },
1039
+ letterWave: {
1040
+ transform: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-transform", "translateY(-0.06em)"),
1041
+ settle: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-settle", "translateY(-0.05em)"),
1042
+ emphasisScale: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-emphasis-scale", "1.08"),
1043
+ durationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-letter-wave-duration", "0.9s"),
1044
+ riseEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-rise-easing", "ease-in-out"),
1045
+ fallEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-fall-easing", "ease-in-out"),
1046
+ },
1010
1047
  instrumental: {
1011
1048
  fillFadeDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-fill-fade-duration", "150ms"),
1012
1049
  fillFadeEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-fade-easing", "ease"),
package/dist/inject.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface AnimationData {
17
17
  }
18
18
  export interface PartData extends AnimationData {
19
19
  highlightElement: HTMLElement;
20
+ letterElements?: HTMLElement[];
21
+ highlightLetterElements?: HTMLElement[];
20
22
  }
21
23
  export type LineData = {
22
24
  parts: PartData[];
package/dist/inject.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // animates is one word, so every part is split on whitespace with its timing pro-rated across the
12
12
  // split by character count. A line that arrives with no timed parts at all is rebuilt the same way
13
13
  // into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
14
- import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
14
+ import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
15
15
  import { getSeekTimeFromClick } from "./seek.js";
16
16
  import { testRtl } from "./text.js";
17
17
  import { registerThemeSetting } from "./themeSettings.js";
@@ -19,6 +19,7 @@ export let disableRichsync = registerThemeSetting("blyrics-disable-richsync", fa
19
19
  let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animation-delay", 50, true);
20
20
  let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
21
21
  let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
22
+ let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
22
23
  const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
23
24
  const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
24
25
  const SPACE_REGEX = /^\s+$/u;
@@ -60,12 +61,14 @@ export function deriveSyncType(lyrics) {
60
61
  return "richsync";
61
62
  return lyrics.every(item => item.startTimeMs === 0) ? "none" : "synced";
62
63
  }
63
- function newPartData(part, span, highlight) {
64
+ function newPartData(part, span, highlight, letterElements, highlightLetterElements) {
64
65
  return {
65
66
  time: part.startTimeMs / 1000,
66
67
  duration: part.durationMs / 1000,
67
68
  lyricElement: span,
68
69
  highlightElement: highlight,
70
+ letterElements,
71
+ highlightLetterElements,
69
72
  animations: [],
70
73
  };
71
74
  }
@@ -195,10 +198,24 @@ function appendLongWordBreaks(doc, span, text, threshold) {
195
198
  }
196
199
  return true;
197
200
  }
198
- function createTimedWordSpan(doc, part, wrapThreshold) {
201
+ function appendLetters(doc, wordElement, text) {
202
+ const chars = [...text];
203
+ wordElement.style.setProperty("--letters", String(chars.length));
204
+ return chars.map((char, index) => {
205
+ const letter = doc.createElement("span");
206
+ letter.classList.add(LETTER_CLASS);
207
+ letter.style.setProperty("--letter-index", String(index));
208
+ letter.textContent = char;
209
+ wordElement.appendChild(letter);
210
+ return letter;
211
+ });
212
+ }
213
+ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
199
214
  const span = doc.createElement("span");
200
215
  const highlight = doc.createElement("span");
201
216
  highlight.classList.add(WORD_HIGHLIGHT_CLASS);
217
+ let letters;
218
+ let highlightLetters;
202
219
  for (const wordElement of [span, highlight]) {
203
220
  wordElement.classList.add(WORD_CLASS);
204
221
  wordElement.dir = "auto";
@@ -214,16 +231,26 @@ function createTimedWordSpan(doc, part, wrapThreshold) {
214
231
  wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
215
232
  if (part.explicit)
216
233
  wordElement.classList.add(EXPLICIT_WORD_CLASS);
217
- appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
234
+ if (perLetter) {
235
+ const collected = appendLetters(doc, wordElement, part.words);
236
+ if (wordElement === span)
237
+ letters = collected;
238
+ else
239
+ highlightLetters = collected;
240
+ }
241
+ else {
242
+ appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
243
+ }
218
244
  wordElement.dataset.time = String(part.startTimeMs / 1000);
219
245
  wordElement.dataset.duration = String(part.durationMs / 1000);
220
246
  wordElement.dataset.content = part.words;
221
247
  wordElement.style.setProperty("--blyrics-duration", part.durationMs + "ms");
222
248
  }
223
- return { span, highlight };
249
+ return { span, highlight, letters, highlightLetters };
224
250
  }
225
251
  function createWordGroup(doc, group, lineData) {
226
252
  const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
253
+ const perLetter = letterWave.getBooleanValue();
227
254
  const lyricGroup = doc.createElement("span");
228
255
  const highlightGroup = doc.createElement("span");
229
256
  for (const groupElement of [lyricGroup, highlightGroup]) {
@@ -240,8 +267,8 @@ function createWordGroup(doc, group, lineData) {
240
267
  for (const token of group.tokens) {
241
268
  if (token.kind === "space")
242
269
  continue;
243
- const { span, highlight } = createTimedWordSpan(doc, token.part, wrapThreshold);
244
- lineData.parts.push(newPartData(token.part, span, highlight));
270
+ const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
271
+ lineData.parts.push(newPartData(token.part, span, highlight, letters, highlightLetters));
245
272
  lyricGroup.appendChild(span);
246
273
  highlightGroup.appendChild(highlight);
247
274
  }
@@ -117,13 +117,13 @@
117
117
 
118
118
  @property --lyric-transition-amount-start {
119
119
  syntax: "<number>";
120
- inherits: false;
120
+ inherits: true;
121
121
  initial-value: -0.2;
122
122
  }
123
123
 
124
124
  @property --lyric-transition-amount-end {
125
125
  syntax: "<number>";
126
- inherits: false;
126
+ inherits: true;
127
127
  initial-value: -0.1;
128
128
  }
129
129
 
@@ -157,6 +157,25 @@
157
157
  );
158
158
  }
159
159
 
160
+ .blyrics--letter {
161
+ display: inline-block;
162
+ }
163
+
164
+ .blyrics-word-highlight:has(.blyrics--letter) {
165
+ background-image: none;
166
+ }
167
+
168
+ .blyrics-word-highlight .blyrics--letter {
169
+ background-image: linear-gradient(
170
+ 90deg,
171
+ var(--blyrics-lyric-active-color)
172
+ calc(100% * (var(--lyric-transition-amount-start) * var(--letters, 1) - var(--letter-index, 0))),
173
+ #00000000
174
+ calc(100% * (var(--lyric-transition-amount-end) * var(--letters, 1) - var(--letter-index, 0)) + 1px)
175
+ );
176
+ background-clip: text;
177
+ }
178
+
160
179
  [blyrics-alt-hover] .blyrics--word:hover {
161
180
  text-decoration: underline;
162
181
  text-underline-offset: 0.15em;
@@ -110,6 +110,13 @@
110
110
  --blyrics-word-wobble-peak-easing: ease-in-out;
111
111
  --blyrics-word-wobble-end-easing: ease-out;
112
112
 
113
+ --blyrics-letter-wave-transform: translateY(-0.05em);
114
+ --blyrics-letter-wave-settle: translateY(-0.02em);
115
+ --blyrics-letter-wave-emphasis-scale: 1.11;
116
+ --blyrics-letter-wave-duration: 0.9s;
117
+ --blyrics-letter-wave-rise-easing: ease-in-out;
118
+ --blyrics-letter-wave-fall-easing: ease-out;
119
+
113
120
  --blyrics-instrumental-fill-fade-duration: 150ms;
114
121
  --blyrics-instrumental-fill-fade-easing: ease;
115
122
  --blyrics-instrumental-fill-transform-from: translateY(78%);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",