@glissade/narrate 0.4.2 → 0.4.4

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AssetRef, AudioClip, Track } from "@glissade/core";
1
+ import { AssetRef, AudioClip, Key, Track } from "@glissade/core";
2
2
  import { FilterSpec, Text } from "@glissade/scene";
3
3
 
4
4
  //#region src/index.d.ts
@@ -92,7 +92,89 @@ declare function captionNode(size: {
92
92
  w: number;
93
93
  h: number;
94
94
  }, style?: CaptionStyle): Text;
95
+ interface DuckOptions {
96
+ /** gain while narration speaks; default 0.25 */
97
+ duck?: number;
98
+ /** gain elsewhere; default 1 */
99
+ base?: number;
100
+ /** ramp-down seconds before a segment starts; default 0.15 */
101
+ attack?: number;
102
+ /** ramp-up seconds after a segment ends; default 0.4 */
103
+ release?: number;
104
+ /**
105
+ * Windows whose gap (after attack/release) is smaller than this stay
106
+ * ducked through — no pumping between close segments. Default 0.5.
107
+ */
108
+ mergeGap?: number;
109
+ /** the music clip's `at` on the timeline; gain keys are CLIP-local. Default 0. */
110
+ clipAt?: number;
111
+ }
112
+ /**
113
+ * The bed-ducking envelope every narrated video needs: duck windows are the
114
+ * narration segments, with attack/release ramps and near-window merging.
115
+ * Pure function of the committed manifest — re-narrate and the ducking
116
+ * re-flows. Returns a keys-only gain envelope for AudioClip.gain.
117
+ */
118
+ declare function duckEnvelope(timing: NarrationTiming, opts?: DuckOptions): {
119
+ keys: Key<number>[];
120
+ };
121
+ /**
122
+ * `<name>.music.timing.json` — committed next to its stem. The load-bearing
123
+ * invariant: BEAT 0 IS SAMPLE 0 of the stem (the prepare step trims the
124
+ * recording to the downbeat); `offsetSec` exists for stems that can't be
125
+ * trimmed (count-ins). Everything derives from bpm/beatsPerCycle — no
126
+ * per-beat marker arrays. Shape blessed from downstream production
127
+ * (TidalCycles render step), where `cps` is the native unit: when present it
128
+ * must agree with bpm/beatsPerCycle.
129
+ */
130
+ interface MusicTiming {
131
+ musicVersion: 1;
132
+ name?: string;
133
+ bpm: number;
134
+ beatsPerCycle: number;
135
+ cycles?: number;
136
+ /** cycles per second — TidalCycles-native; must equal bpm / (60 · beatsPerCycle) */
137
+ cps?: number;
138
+ durationSec: number;
139
+ /** seconds into the stem where beat 0 sits; default 0 (trimmed-to-downbeat) */
140
+ offsetSec?: number;
141
+ /** stem audio file, relative to the manifest — required for render auto-mix */
142
+ stem?: string;
143
+ /** bed level in dB applied to the clip gain (auto-mix and clip()); default 0 */
144
+ gainDb?: number;
145
+ /** provenance (e.g. the .tidal pattern source) */
146
+ source?: string;
147
+ }
148
+ interface MusicClipOptions {
149
+ /** bed level in dB; overrides the manifest's gainDb */
150
+ gainDb?: number;
151
+ /** auto-duck under this narration (windows from its segments) */
152
+ duckUnder?: NarrationTiming;
153
+ duckOpts?: Omit<DuckOptions, 'clipAt'>;
154
+ }
155
+ interface MusicAnchors {
156
+ /** timeline second of beat n (beat 0 = clip at + offsetSec) */
157
+ beat(n: number): number;
158
+ /** timeline second of cycle n (beatsPerCycle beats each) */
159
+ cycle(n: number): number;
160
+ /** quantize t to the closest beat */
161
+ nearestBeat(t: number): number;
162
+ /** quantize t forward to the next beat (what choreography reaches for) */
163
+ nextBeat(t: number): number;
164
+ readonly beatLen: number;
165
+ readonly durationSec: number;
166
+ /** the grid parameters, for external quantizers */
167
+ grid(): {
168
+ bpm: number;
169
+ offsetSec: number;
170
+ };
171
+ /** the stem as an AudioClip, with bed gain and optional narration ducking composed */
172
+ clip(url?: string, opts?: MusicClipOptions): AudioClip;
173
+ }
174
+ declare function validateMusicTiming(timing: MusicTiming): void;
175
+ /** Beat-grid anchors over a music manifest; `at` places the clip on the timeline. */
176
+ declare function music(timing: MusicTiming, at?: number): MusicAnchors;
95
177
  declare function toSrt(timing: NarrationTiming): string;
96
178
  declare function toVtt(timing: NarrationTiming): string;
97
179
  //#endregion
98
- export { CaptionStyle, CaptionTrackOptions, NarrationAnchors, NarrationError, NarrationScript, NarrationSegment, NarrationTiming, TimedSegment, TimedWord, captionNode, captionTrack, narration, toSrt, toVtt };
180
+ export { CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationError, NarrationScript, NarrationSegment, NarrationTiming, TimedSegment, TimedWord, captionNode, captionTrack, duckEnvelope, music, narration, toSrt, toVtt, validateMusicTiming };
package/dist/index.js CHANGED
@@ -87,6 +87,94 @@ function captionNode(size, style = {}) {
87
87
  filters: style.filters ?? glow("#000000cc", 3, 1)
88
88
  });
89
89
  }
90
+ /**
91
+ * The bed-ducking envelope every narrated video needs: duck windows are the
92
+ * narration segments, with attack/release ramps and near-window merging.
93
+ * Pure function of the committed manifest — re-narrate and the ducking
94
+ * re-flows. Returns a keys-only gain envelope for AudioClip.gain.
95
+ */
96
+ function duckEnvelope(timing, opts = {}) {
97
+ const duck = opts.duck ?? .25;
98
+ const base = opts.base ?? 1;
99
+ const attack = opts.attack ?? .15;
100
+ const release = opts.release ?? .4;
101
+ const mergeGap = opts.mergeGap ?? .5;
102
+ const clipAt = opts.clipAt ?? 0;
103
+ const windows = [];
104
+ for (const s of [...timing.segments].sort((a, b) => a.start - b.start)) {
105
+ const last = windows[windows.length - 1];
106
+ if (last && s.start - last.end < attack + release + mergeGap) last.end = Math.max(last.end, s.start + s.duration);
107
+ else windows.push({
108
+ start: s.start,
109
+ end: s.start + s.duration
110
+ });
111
+ }
112
+ const keys = [];
113
+ for (const w of windows) {
114
+ const rampStart = w.start - attack - clipAt;
115
+ const down = w.start - clipAt;
116
+ const up = w.end - clipAt;
117
+ const rampEnd = w.end + release - clipAt;
118
+ if (rampEnd <= 0) continue;
119
+ if (rampStart > 0) keys.push(key(rampStart, base));
120
+ if (down > 0) keys.push(key(down, duck));
121
+ else if (keys.length === 0) keys.push(key(0, duck));
122
+ keys.push(key(Math.max(up, 1e-6), duck));
123
+ keys.push(key(rampEnd, base));
124
+ }
125
+ if (keys.length === 0) keys.push(key(0, base));
126
+ if (keys[0].t > 0) keys.unshift(key(0, base));
127
+ return { keys };
128
+ }
129
+ function validateMusicTiming(timing) {
130
+ if (timing.musicVersion !== 1) throw new NarrationError(`unsupported musicVersion ${String(timing.musicVersion)}`);
131
+ if (!(timing.bpm > 0) || !(timing.beatsPerCycle > 0)) throw new NarrationError("music timing needs bpm > 0 and beatsPerCycle > 0");
132
+ if (timing.cps !== void 0) {
133
+ const expected = timing.bpm / (60 * timing.beatsPerCycle);
134
+ if (Math.abs(timing.cps - expected) > 1e-9) throw new NarrationError(`music timing cps (${timing.cps}) disagrees with bpm/beatsPerCycle (expected ${expected})`);
135
+ }
136
+ }
137
+ /** Beat-grid anchors over a music manifest; `at` places the clip on the timeline. */
138
+ function music(timing, at = 0) {
139
+ validateMusicTiming(timing);
140
+ const beatLen = 60 / timing.bpm;
141
+ const beat0 = at + (timing.offsetSec ?? 0);
142
+ return {
143
+ beat: (n) => beat0 + n * beatLen,
144
+ cycle: (n) => beat0 + n * timing.beatsPerCycle * beatLen,
145
+ nearestBeat: (t) => beat0 + Math.round((t - beat0) / beatLen) * beatLen,
146
+ nextBeat: (t) => beat0 + Math.ceil((t - beat0 - 1e-9) / beatLen) * beatLen,
147
+ beatLen,
148
+ durationSec: timing.durationSec,
149
+ grid: () => ({
150
+ bpm: timing.bpm,
151
+ offsetSec: beat0
152
+ }),
153
+ clip: (url, opts = {}) => {
154
+ const src = url ?? timing.stem;
155
+ if (!src) throw new NarrationError("music clip needs a url (or a stem field in the manifest)");
156
+ const gainDb = opts.gainDb ?? timing.gainDb ?? 0;
157
+ const scale = Math.pow(10, gainDb / 20);
158
+ let keys = null;
159
+ if (opts.duckUnder) keys = duckEnvelope(opts.duckUnder, {
160
+ ...opts.duckOpts,
161
+ clipAt: at
162
+ }).keys;
163
+ else if (gainDb !== 0) keys = [key(0, 1)];
164
+ return {
165
+ asset: {
166
+ kind: "audio",
167
+ url: src
168
+ },
169
+ at,
170
+ ...keys !== null ? { gain: { keys: keys.map((k) => ({
171
+ ...k,
172
+ value: k.value * scale
173
+ })) } } : {}
174
+ };
175
+ }
176
+ };
177
+ }
90
178
  function srtTime(t, sep) {
91
179
  const ms = Math.round(t * 1e3);
92
180
  const h = Math.floor(ms / 36e5);
@@ -103,4 +191,4 @@ function toVtt(timing) {
103
191
  return "WEBVTT\n\n" + timing.segments.map((s) => `${srtTime(s.start, ".")} --> ${srtTime(s.start + s.duration, ".")}\n${s.text}`).join("\n\n") + "\n";
104
192
  }
105
193
  //#endregion
106
- export { NarrationError, captionNode, captionTrack, narration, toSrt, toVtt };
194
+ export { NarrationError, captionNode, captionTrack, duckEnvelope, music, narration, toSrt, toVtt, validateMusicTiming };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/narrate",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "glissade narration + captions: TTS at prepare time (gs narrate), deterministic caching, narration-anchored timeline beats, and captions as plain tracks. Render stays offline.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,8 +19,8 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@glissade/core": "0.4.2",
23
- "@glissade/scene": "0.4.2"
22
+ "@glissade/core": "0.4.4",
23
+ "@glissade/scene": "0.4.4"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",