@olegkoval/agent-skills 1.60.0 → 1.62.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.
Files changed (223) hide show
  1. package/.github/copilot-instructions.md +2 -0
  2. package/.github/prompts/carousel-maker.prompt.md +102 -0
  3. package/.github/prompts/shorts-animator.prompt.md +103 -0
  4. package/.kiro/steering/carousel-maker.md +101 -0
  5. package/.kiro/steering/shorts-animator.md +102 -0
  6. package/.windsurf/rules/carousel-maker.md +100 -0
  7. package/.windsurf/rules/shorts-animator.md +101 -0
  8. package/README.md +4 -2
  9. package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  10. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  11. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  12. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  13. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  14. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  15. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  16. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  17. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  18. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  19. package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  20. package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  21. package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  22. package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  23. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  24. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  25. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  26. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  27. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  28. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  29. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  30. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  31. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  32. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  33. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  34. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  35. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  36. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  37. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  38. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  39. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  40. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  41. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  42. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  43. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  44. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  45. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  46. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  47. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  48. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  49. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  50. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  51. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  52. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  53. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  54. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  55. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  56. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  57. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  58. package/adapters/claude/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  59. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  60. package/adapters/codex/olko-creative/README.md +2 -0
  61. package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  62. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  63. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  64. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  65. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  66. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  67. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  68. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  69. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  70. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  71. package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  72. package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  73. package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  74. package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  75. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  76. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  77. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  78. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  79. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  80. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  81. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  82. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  83. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  84. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  85. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  86. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  87. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  88. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  89. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  90. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  91. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  92. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  93. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  94. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  95. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  96. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  97. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  98. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  99. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  100. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  101. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  102. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  103. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  104. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  105. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  106. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  107. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  108. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  109. package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  110. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  111. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  112. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  113. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  114. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  115. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  116. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  117. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  118. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  119. package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  120. package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  121. package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  122. package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  123. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  124. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  125. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  126. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  127. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  128. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  129. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  130. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  131. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  132. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  133. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  134. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  135. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  136. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  137. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  138. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  139. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  140. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  141. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  142. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  143. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  144. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  145. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  146. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  147. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  148. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  149. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  150. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  151. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  152. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  153. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  154. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  155. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  156. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  157. package/adapters/hermes/olko-creative/README.md +2 -0
  158. package/catalog/skills.json +46 -0
  159. package/package.json +1 -1
  160. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  161. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  162. package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
  163. package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  164. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  165. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  166. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  167. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  168. package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  169. package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  170. package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  171. package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  172. package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
  173. package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  174. package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  175. package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  176. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  177. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  178. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  179. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  180. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  181. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  182. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  183. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  184. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  185. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  186. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  187. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  188. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  189. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  190. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  191. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  192. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  193. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  194. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  195. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  196. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  197. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  198. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  199. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  200. package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  201. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  202. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  203. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  204. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  205. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  206. package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  207. package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  208. package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  209. package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  210. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  211. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  212. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  213. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  214. package/plugins/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  215. package/plugins/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  216. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +46 -3
  217. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  218. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  219. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  220. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  221. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  222. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
  223. package/scripts/lib/catalog.mjs +2 -1
@@ -0,0 +1,193 @@
1
+ // DSP kit: rng, filters, oscillators, envelopes, noise, reverb, delay, mixing.
2
+ // The only state is a typed array a function allocates (or is handed as its output buffer), plus a filter's
3
+ // own memory and a seeded rng closure.
4
+ export const SR = 48000;
5
+ export const TAU = Math.PI * 2;
6
+
7
+ export type Rng = () => number;
8
+ export type Stereo = { readonly L: Float32Array; readonly R: Float32Array };
9
+ export type Bus = Stereo & { readonly n: number };
10
+ export type Voice = Float32Array | Stereo;
11
+
12
+ export const rng = (seed: number): Rng => {
13
+ let s = seed >>> 0 || 1;
14
+ return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
15
+ };
16
+ export const human = (r: Rng, amt = 0.1): number => 1 - amt / 2 + r() * amt;
17
+ export const mtof = (m: number): number => 440 * Math.pow(2, (m - 69) / 12);
18
+ export const dbToGain = (db: number): number => Math.pow(10, db / 20);
19
+ export const clamp = (x: number, a: number, b: number): number => Math.max(a, Math.min(b, x));
20
+
21
+ // ---------- buses and mixing ----------
22
+ export function makeBus(sec: number): Bus {
23
+ const n = Math.ceil(sec * SR);
24
+ return { L: new Float32Array(n), R: new Float32Array(n), n };
25
+ }
26
+ const isStereo = (v: Voice): v is Stereo => !(v instanceof Float32Array);
27
+
28
+ // mix a mono or stereo voice in at time `at` (seconds), equal-power pan -1..1
29
+ export function busAdd(bus: Bus, v: Voice, at: number, gain = 1, pan = 0): void {
30
+ const i0 = Math.round(at * SR);
31
+ const pl = Math.cos((pan + 1) * Math.PI / 4) * Math.SQRT2, pr = Math.sin((pan + 1) * Math.PI / 4) * Math.SQRT2;
32
+ const vl = isStereo(v) ? v.L : v, vr = isStereo(v) ? v.R : v;
33
+ for (let i = 0; i < vl.length; i++) {
34
+ const j = i0 + i;
35
+ if (j < 0) continue;
36
+ if (j >= bus.n) break;
37
+ bus.L[j] += vl[i] * gain * pl;
38
+ bus.R[j] += vr[i] * gain * pr;
39
+ }
40
+ }
41
+ export function busMixFrom(dst: Bus, b: Bus, gain = 1): void {
42
+ for (let i = 0; i < dst.n && i < b.n; i++) { dst.L[i] += b.L[i] * gain; dst.R[i] += b.R[i] * gain; }
43
+ }
44
+ export function busGain(bus: Bus, k: number): void {
45
+ for (let i = 0; i < bus.n; i++) { bus.L[i] *= k; bus.R[i] *= k; }
46
+ }
47
+
48
+ // ---------- typed-array helpers ----------
49
+ export const tabulate = (n: number, f: (i: number) => number): Float32Array => Float32Array.from({ length: n }, (_, i) => f(i));
50
+ export function addInto(dst: Float32Array, src: Float32Array, off: number, f: (x: number) => number): void {
51
+ for (let i = 0; i < src.length && i + off < dst.length; i++) dst[i + off] += f(src[i]);
52
+ }
53
+ export function scaleBy(a: Float32Array, g: (i: number) => number): Float32Array {
54
+ for (let i = 0; i < a.length; i++) a[i] *= g(i);
55
+ return a;
56
+ }
57
+ export function mulInto(a: Float32Array, b: Float32Array): Float32Array {
58
+ for (let i = 0; i < a.length; i++) a[i] *= b[i] ?? 0;
59
+ return a;
60
+ }
61
+
62
+ // ---------- RBJ biquad ----------
63
+ export type FilterType = "lp" | "hp" | "bp" | "lowshelf" | "highshelf";
64
+ type Coefs = readonly [number, number, number, number, number, number];
65
+ type Terms = { readonly cs: number; readonly a: number; readonly A: number };
66
+
67
+ const shelf = (sg: number) => ({ cs, a, A }: Terms): Coefs => {
68
+ const s = Math.sqrt(A) * 2 * a;
69
+ return [
70
+ A * ((A + 1) - sg * (A - 1) * cs + s), 2 * sg * A * ((A - 1) - sg * (A + 1) * cs), A * ((A + 1) - sg * (A - 1) * cs - s),
71
+ (A + 1) + sg * (A - 1) * cs + s, -2 * sg * ((A - 1) + sg * (A + 1) * cs), (A + 1) + sg * (A - 1) * cs - s,
72
+ ];
73
+ };
74
+ const COEF: Record<FilterType, (t: Terms) => Coefs> = {
75
+ lp: ({ cs, a }) => { const b0 = (1 - cs) / 2; return [b0, 1 - cs, b0, 1 + a, -2 * cs, 1 - a]; },
76
+ hp: ({ cs, a }) => { const b0 = (1 + cs) / 2; return [b0, -(1 + cs), b0, 1 + a, -2 * cs, 1 - a]; },
77
+ bp: ({ cs, a }) => [a, 0, -a, 1 + a, -2 * cs, 1 - a],
78
+ lowshelf: shelf(1),
79
+ highshelf: shelf(-1),
80
+ };
81
+ function coef(type: FilterType, f: number, q: number, db: number): readonly [number, number, number, number, number] {
82
+ const w = TAU * clamp(f, 10, SR / 2.2) / SR, cs = Math.cos(w), sn = Math.sin(w);
83
+ const [b0, b1, b2, a0, a1, a2] = COEF[type]({ cs, a: sn / (2 * q), A: Math.pow(10, db / 40) });
84
+ return [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0];
85
+ }
86
+ export type Filter = (x: number) => number;
87
+ export type Biquad = Filter & { set(f: number, q?: number): void };
88
+
89
+ // the filter's own memory is a closure; `set` retunes it mid-stream (the riser sweeps its band)
90
+ export function biquad(type: FilterType, f: number, q = 0.707, db = 0): Biquad {
91
+ let c = coef(type, f, q, db);
92
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
93
+ const run: Filter = (x) => { const y = c[0] * x + c[1] * x1 + c[2] * x2 - c[3] * y1 - c[4] * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; };
94
+ const set = (nf: number, nq = q): void => { c = coef(type, nf, nq, db); };
95
+ return Object.assign(run, { set });
96
+ }
97
+ export function filt(buf: Float32Array, ...fs: readonly Filter[]): Float32Array {
98
+ for (const f of fs) for (let i = 0; i < buf.length; i++) buf[i] = f(buf[i]);
99
+ return buf;
100
+ }
101
+
102
+ // ---------- oscillators, envelopes, noise ----------
103
+ // band-limited saw (polyBLEP)
104
+ function blep(t: number, dt: number): number {
105
+ if (t < dt) { const u = t / dt; return u + u - u * u - 1; }
106
+ if (t > 1 - dt) { const u = (t - 1) / dt; return u * u + u + u + 1; }
107
+ return 0;
108
+ }
109
+ const SHAPES = {
110
+ sine: (ph: number) => Math.sin(TAU * ph),
111
+ saw: (ph: number, dt: number) => 2 * ph - 1 - blep(ph, dt),
112
+ };
113
+ export function osc(kind: keyof typeof SHAPES, freq: number, sec: number, phase = 0): Float32Array {
114
+ const n = Math.ceil(sec * SR), out = new Float32Array(n), shape = SHAPES[kind], dt = freq / SR;
115
+ let ph = phase;
116
+ for (let i = 0; i < n; i++) {
117
+ out[i] = shape(ph, dt);
118
+ ph += dt; if (ph >= 1) ph -= 1;
119
+ }
120
+ return out;
121
+ }
122
+ // attack / decay / sustain / release envelope over `sec`
123
+ export function adsr(sec: number, a: number, d: number, s: number, r: number): Float32Array {
124
+ const n = Math.ceil(sec * SR), A = a * SR, D = d * SR, R = r * SR, hold = n - R;
125
+ return tabulate(n, (i) => {
126
+ const v = i < A ? i / A : i < A + D ? 1 - (1 - s) * ((i - A) / D) : s;
127
+ return i > hold ? v * Math.max(0, 1 - (i - hold) / R) : v;
128
+ });
129
+ }
130
+ export const expDecay = (sec: number, tau: number): Float32Array => tabulate(Math.ceil(sec * SR), (i) => Math.exp(-i / (tau * SR)));
131
+
132
+ export function noise(sec: number, r: Rng, pink = false): Float32Array {
133
+ const n = Math.ceil(sec * SR), o = new Float32Array(n);
134
+ if (!pink) { for (let i = 0; i < n; i++) o[i] = r() * 2 - 1; return o; }
135
+ let b0 = 0, b1 = 0, b2 = 0;
136
+ for (let i = 0; i < n; i++) {
137
+ const w = r() * 2 - 1;
138
+ b0 = 0.99765 * b0 + w * 0.099046; b1 = 0.963 * b1 + w * 0.2965164; b2 = 0.57 * b2 + w * 1.0526913;
139
+ o[i] = (b0 + b1 + b2 + w * 0.1848) * 0.2;
140
+ }
141
+ return o;
142
+ }
143
+
144
+ // ---------- Freeverb (Jezar), stereo, tuned for 48 kHz ----------
145
+ type Lines = { readonly buf: readonly Float32Array[]; readonly idx: Int32Array };
146
+ const makeLines = (taps: readonly number[], spread: number): Lines => ({ buf: taps.map((t) => new Float32Array(t + spread)), idx: new Int32Array(taps.length) });
147
+
148
+ function combSum(c: Lines, flt: Float64Array, x: number, room: number, damp: number): number {
149
+ let y = 0;
150
+ for (let j = 0; j < c.buf.length; j++) {
151
+ const b = c.buf[j], o = b[c.idx[j]];
152
+ flt[j] = o * (1 - damp) + flt[j] * damp;
153
+ b[c.idx[j]] = x + flt[j] * room;
154
+ c.idx[j] = (c.idx[j] + 1) % b.length;
155
+ y += o;
156
+ }
157
+ return y;
158
+ }
159
+ function allpass(a: Lines, input: number): number {
160
+ let y = input;
161
+ for (let j = 0; j < a.buf.length; j++) {
162
+ const b = a.buf[j], o = b[a.idx[j]], v = -y + o;
163
+ b[a.idx[j]] = y + o * 0.5;
164
+ a.idx[j] = (a.idx[j] + 1) % b.length;
165
+ y = v;
166
+ }
167
+ return y;
168
+ }
169
+ export function reverb(bus: Bus, { room = 0.82, damp = 0.35, wet = 1 } = {}): Bus {
170
+ const k = SR / 44100;
171
+ const combT = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617].map((x) => Math.round(x * k));
172
+ const apT = [556, 441, 341, 225].map((x) => Math.round(x * k));
173
+ const out = makeBus(bus.n / SR);
174
+ const channels: readonly (readonly [Float32Array, Float32Array, number])[] = [[bus.L, out.L, 0], [bus.R, out.R, Math.round(23 * k)]];
175
+ for (const [src, dst, spread] of channels) {
176
+ const combs = makeLines(combT, spread), aps = makeLines(apT, spread), flt = new Float64Array(combT.length);
177
+ for (let n = 0; n < bus.n; n++) dst[n] = allpass(aps, combSum(combs, flt, src[n] * 0.015, room, damp)) * wet;
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // ping-pong delay
183
+ export function delay(bus: Bus, time: number, fb = 0.35, lp = 4500): Bus {
184
+ const d = Math.round(time * SR), out = makeBus(bus.n / SR);
185
+ const bl = new Float32Array(d), br = new Float32Array(d);
186
+ const fl = biquad("lp", lp), fr = biquad("lp", lp);
187
+ for (let i = 0, j = 0; i < bus.n; i++, j = (j + 1) % d) {
188
+ const ol = bl[j], or = br[j];
189
+ bl[j] = fl(bus.L[i] + or * fb); br[j] = fr(bus.R[i] * 0.4 + ol * fb);
190
+ out.L[i] = ol; out.R[i] = or;
191
+ }
192
+ return out;
193
+ }
@@ -0,0 +1,185 @@
1
+ // Voiced to sound played, not like a ringtone: electric piano, felt plucks, warm pads, a round sub and
2
+ // soft drums, all in the low and middle register. Nothing melodic sits above about B4, no bare sine or
3
+ // square plays up high, and every source is low-passed before it reaches the mix.
4
+ import {
5
+ SR, TAU, addInto, adsr, biquad, expDecay, filt, human, mtof, mulInto, noise, osc, scaleBy, tabulate,
6
+ } from "./dsp.ts";
7
+ import type { Rng, Stereo } from "./dsp.ts";
8
+
9
+ // ---------- drums ----------
10
+ type KickOpts = { pitch?: number; dec?: number; punch?: number };
11
+
12
+ function kickBody(n: number, pitch: number, dec: number, punch: number): Float32Array {
13
+ const o = new Float32Array(n);
14
+ let ph = 0;
15
+ for (let i = 0; i < n; i++) {
16
+ const t = i / SR;
17
+ ph += pitch * (1 + 1.1 * punch * Math.exp(-t / 0.035)) / SR;
18
+ o[i] = Math.sin(TAU * ph) * Math.exp(-t / (dec * 0.45)) * Math.min(1, i / 48);
19
+ }
20
+ return o;
21
+ }
22
+ // round kick: a short sweep down to the fundamental, a felt click, a little saturation
23
+ export function kick(r: Rng, { pitch = 50, dec = 0.36, punch = 1 }: KickOpts = {}): Float32Array {
24
+ const sec = dec + 0.12, n = Math.ceil(sec * SR), o = kickBody(n, pitch, dec, punch);
25
+ const click = filt(mulInto(noise(0.01, r), expDecay(0.01, 0.002)), biquad("lp", 1400));
26
+ addInto(o, click, 0, (x) => x * 0.18 * punch);
27
+ for (let i = 0; i < n; i++) o[i] = Math.tanh(o[i] * 1.3) / Math.tanh(1.3);
28
+ return o;
29
+ }
30
+
31
+ type SnareOpts = { tone?: number; dec?: number; top?: number };
32
+ // a soft, dusty snare: a short tuned body and a band of noise with the top rolled off
33
+ export function snare(r: Rng, { tone = 190, dec = 0.14, top = 7000 }: SnareOpts = {}): Float32Array {
34
+ const sec = dec * 3, n = Math.ceil(sec * SR), o = new Float32Array(n);
35
+ let ph = 0;
36
+ for (let i = 0; i < n; i++) { const t = i / SR; ph += tone * (1 + 0.25 * Math.exp(-t / 0.01)) / SR; o[i] = Math.sin(TAU * ph) * Math.exp(-t / 0.045) * 0.7; }
37
+ const nz = filt(mulInto(noise(sec, r, true), expDecay(sec, dec * 0.6)), biquad("bp", 1700, 0.6), biquad("lp", top), biquad("hp", 300));
38
+ return tabulate(n, (i) => (o[i] + nz[i] * 3.2) * Math.min(1, i / 72));
39
+ }
40
+ // brush: a slow-onset sweep of noise, no crack
41
+ export function brush(r: Rng): Float32Array {
42
+ const o = filt(noise(0.28, r, true), biquad("bp", 2200, 0.5), biquad("lp", 7000), biquad("hp", 450));
43
+ return scaleBy(o, (i) => { const t = i / SR; return Math.min(1, t / 0.018) * Math.exp(-t / 0.09) * 2.4; });
44
+ }
45
+ // a closed hat with the fizz taken off (nothing above ~9 kHz)
46
+ export function hat(r: Rng): Float32Array {
47
+ const o = filt(noise(0.07, r), biquad("hp", 5600, 0.7), biquad("lp", 11000));
48
+ return scaleBy(o, (i) => { const t = i / SR; return Math.min(1, i / 96) * Math.exp(-t / 0.018); });
49
+ }
50
+ // shaker: a short grain of high noise with a soft onset (the light percussion of a calm bed)
51
+ export function shaker(r: Rng): Float32Array {
52
+ const o = filt(noise(0.09, r), biquad("bp", 4200, 1.1), biquad("lp", 9000));
53
+ return scaleBy(o, (i) => { const t = i / SR; return Math.min(1, t / 0.008) * Math.exp(-t / 0.03) * 1.6; });
54
+ }
55
+ function crackle(o: Float32Array, i: number, r: Rng): void {
56
+ const a = (r() * 0.6 + 0.2) * (r() < 0.5 ? -1 : 1);
57
+ for (let k = 0; k < 30 && i + k < o.length; k++) o[i + k] += a * Math.exp(-k / 6);
58
+ }
59
+ // vinyl: sparse soft crackle and a little low hiss (the lofi bed's air)
60
+ export function vinyl(sec: number, r: Rng): Float32Array {
61
+ const n = Math.ceil(sec * SR), o = new Float32Array(n);
62
+ for (let i = 0; i < n; i++) if (r() < 0.00035) crackle(o, i, r);
63
+ addInto(o, noise(sec, r, true), 0, (x) => x * 0.05);
64
+ return filt(o, biquad("lp", 3200), biquad("hp", 250));
65
+ }
66
+
67
+ // ---------- bass and keys ----------
68
+ type BassOpts = { a?: number; r?: number; drive?: number; tone?: number };
69
+ // sub bass: a sine with a touch of its own second harmonic, so it still reads on a phone speaker
70
+ export function bass(note: number, sec: number, { a = 0.006, r = 0.08, drive = 1.3, tone = 0.18 }: BassOpts = {}): Float32Array {
71
+ const f = mtof(note), n = Math.ceil(sec * SR), env = adsr(sec, a, 0.18, 0.75, r);
72
+ const o = tabulate(n, (i) => {
73
+ const t = i / SR, x = Math.sin(TAU * f * t) + tone * Math.sin(TAU * 2 * f * t + 0.6);
74
+ return Math.tanh(x * drive) / Math.tanh(drive) * env[i];
75
+ });
76
+ return filt(o, biquad("lp", 520));
77
+ }
78
+
79
+ // electric piano (FM, Rhodes-like): a 1:1 pair for the body and the bark of a harder hit, the tine
80
+ // short and quiet, rounder the softer it's played; low notes ring longer. `rh` feeds the hammer noise.
81
+ export function ep(note: number, sec: number, vel: number, rh: Rng): Float32Array {
82
+ const f = mtof(note), n = Math.ceil(sec * SR);
83
+ const ham = scaleBy(filt(mulInto(noise(0.03, rh), expDecay(0.03, 0.006)), biquad("bp", 2600, 0.7)), () => 1.6 * vel);
84
+ const idx = 0.5 + 1.7 * vel, tau = 0.7 + 1.8 * Math.exp(-(note - 48) / 16), rel = Math.min(n, Math.round(0.09 * SR));
85
+ const o = tabulate(n, (i) => {
86
+ const t = i / SR, I = idx * Math.exp(-t / 0.25) + 0.15;
87
+ const body = Math.sin(TAU * f * t + I * Math.sin(TAU * f * t));
88
+ const tine = Math.sin(TAU * f * 4 * t) * Math.exp(-t / 0.025) * 0.12 * vel + (ham[i] || 0);
89
+ const e = Math.min(1, i / (0.003 * SR)) * Math.exp(-t / tau);
90
+ return (body + tine) * (i > n - rel ? e * ((n - i) / rel) : e) * (0.55 + 0.45 * vel);
91
+ });
92
+ return filt(o, biquad("lp", 2200 + 3000 * vel, 0.6), biquad("hp", 70));
93
+ }
94
+ type ChordOpts = { roll?: number; trem?: number };
95
+ // a chord on the electric piano: slightly rolled, with a slow stereo tremolo
96
+ export function epChord(notes: readonly number[], sec: number, vel: number, r: Rng, rh: Rng, { roll = 0.012, trem = 0.18 }: ChordOpts = {}): Stereo {
97
+ const n = Math.ceil((sec + roll * notes.length) * SR), L = new Float32Array(n), R = new Float32Array(n);
98
+ const share = (x: number) => x / notes.length * 1.8;
99
+ notes.forEach((m, k) => {
100
+ const v = ep(m, sec, vel * human(r, 0.16), rh), off = Math.round(k * roll * SR * human(r, 0.6));
101
+ addInto(L, v, off, share); addInto(R, v, off, share);
102
+ });
103
+ const ph = r() * TAU;
104
+ for (let i = 0; i < n; i++) { const p = trem * Math.sin(TAU * 4.6 * i / SR + ph); L[i] *= 1 - p; R[i] *= 1 + p; }
105
+ return { L, R };
106
+ }
107
+
108
+ // ---------- plucks, pads, synth ----------
109
+ type PluckOpts = { bright?: number; decay?: number };
110
+ // felt pluck (Karplus-Strong, dark): muted guitar, kalimba or felt piano, with a warm body under it
111
+ export function pluck(note: number, sec: number, r: Rng, { bright = 0.25, decay = 0.995 }: PluckOpts = {}): Float32Array {
112
+ const f = mtof(note), p = Math.max(2, Math.round(SR / f)), n = Math.ceil(sec * SR), o = new Float32Array(n);
113
+ const buf = new Float32Array(p);
114
+ for (let i = 0; i < p; i++) buf[i] = r() * 2 - 1;
115
+ const pre = biquad("lp", 500 + bright * 3000);
116
+ filt(buf, pre, pre);
117
+ let last = 0;
118
+ for (let i = 0; i < n; i++) {
119
+ const j = i % p, v = buf[j];
120
+ buf[j] = (v + last) * 0.5 * decay;
121
+ last = v; o[i] = v;
122
+ }
123
+ for (let i = 0; i < n; i++) o[i] += Math.sin(TAU * f * i / SR) * Math.exp(-i / SR / 0.25) * 0.25;
124
+ return filt(mulInto(o, adsr(sec, 0.003, 0.05, 1, 0.1)), biquad("hp", 90), biquad("lp", 5200));
125
+ }
126
+ // a strummed chord on the felt pluck (the guitar's ending)
127
+ export function strum(notes: readonly number[], sec: number, r: Rng, o: PluckOpts = {}): Float32Array {
128
+ const n = Math.ceil((sec + 0.2) * SR), out = new Float32Array(n);
129
+ notes.forEach((m, k) => addInto(out, pluck(m, sec, r, o), Math.round(k * 0.028 * SR), (x) => x * 0.55));
130
+ return out;
131
+ }
132
+
133
+ type PadOpts = { cutoff?: number; attack?: number; release?: number; detune?: number };
134
+ // the saws, detuned across a note's four voices and split left/right, summed before filtering
135
+ function padSaws(notes: readonly number[], sec: number, r: Rng, detune: number): Stereo {
136
+ const n = Math.ceil(sec * SR), L = new Float32Array(n), R = new Float32Array(n);
137
+ for (const m of notes) {
138
+ for (let k = 0; k < 4; k++) addInto(k % 2 ? R : L, osc("saw", mtof(m + (k - 1.5) * detune / 1.5), sec, r()), 0, (x) => x * 0.16);
139
+ }
140
+ return { L, R };
141
+ }
142
+ // a warm pad: detuned saws through a steep low-pass, a sine an octave under, slow in and out, stereo
143
+ export function pad(notes: readonly number[], sec: number, r: Rng, { cutoff = 2600, attack = 0.8, release = 1.4, detune = 0.07 }: PadOpts = {}): Stereo {
144
+ const { L, R } = padSaws(notes, sec, r, detune), n = L.length;
145
+ const lo = osc("sine", mtof(notes[0] - 12), sec);
146
+ const env = adsr(sec, attack, 0.5, 0.85, release);
147
+ const f = [biquad("lp", cutoff, 0.55), biquad("lp", cutoff, 0.55), biquad("lp", cutoff * 1.1, 0.55), biquad("lp", cutoff * 1.1, 0.55)];
148
+ const h = [biquad("hp", 110), biquad("hp", 110)];
149
+ const g = 2.2 / notes.length;
150
+ const air = filt(noise(sec, r, true), biquad("bp", 3800, 0.6), biquad("lp", 9000));
151
+ for (let i = 0; i < n; i++) {
152
+ L[i] = (h[0](f[2](f[0](L[i]))) * g + lo[i] * 0.12 + air[i] * 0.1) * env[i];
153
+ R[i] = (h[1](f[3](f[1](R[i]))) * g + lo[i] * 0.12 - air[i] * 0.1) * env[i];
154
+ }
155
+ return { L, R };
156
+ }
157
+ // a soft, low synth note (Terminal's arpeggio): a saw through a low filter
158
+ export function softSynth(note: number, sec: number, cut = 1800): Float32Array {
159
+ const o = osc("saw", mtof(note), sec), f = biquad("lp", cut, 0.7);
160
+ for (let i = 0; i < o.length; i++) { const t = i / SR; o[i] = f(o[i]) * Math.min(1, i / 120) * Math.exp(-t / (sec * 0.6)); }
161
+ return o;
162
+ }
163
+
164
+ // ---------- the ending ----------
165
+ // a low, dark swell that grows into its end (a hiss climbing to the sky is what sounds cheap)
166
+ export function outroRiser(r: Rng, sec: number): Stereo {
167
+ const n = Math.ceil(sec * SR), L = new Float32Array(n), R = new Float32Array(n);
168
+ const src = noise(sec, r, true), bl = biquad("bp", 140, 0.8), br = biquad("bp", 140, 0.8), ll = biquad("lp", 2200), lr = biquad("lp", 2200);
169
+ const fo = Math.round(0.04 * SR);
170
+ for (let i = 0; i < n; i++) {
171
+ const k = i / n, f = 140 * Math.pow(1600 / 140, k * k);
172
+ if (i % 32 === 0) { bl.set(f, 0.8); br.set(f * 1.04, 0.8); }
173
+ const env = Math.pow(k, 2.2) * (i > n - fo ? (n - i) / fo : 1) * 2.6;
174
+ L[i] = ll(bl(src[i])) * env; R[i] = lr(br(src[i])) * env;
175
+ }
176
+ return { L, R };
177
+ }
178
+ // one deep hit with a breath of air after it
179
+ export function outroImpact(r: Rng, note: number): Stereo {
180
+ const n = Math.ceil(2.4 * SR), L = new Float32Array(n), R = new Float32Array(n);
181
+ const boom = filt(kick(r, { pitch: mtof(note - 24) * 0.9, punch: 0.4, dec: 1.4 }), biquad("lp", 700));
182
+ const air = filt(mulInto(noise(1.3, r, true), expDecay(1.3, 0.3)), biquad("lp", 1100), biquad("hp", 90));
183
+ for (let i = 0; i < n; i++) { const b = boom[i] || 0, a = air[i] || 0; L[i] = b * 0.9 + a * 0.45; R[i] = b * 0.9 + a * 0.4; }
184
+ return { L, R };
185
+ }
@@ -0,0 +1,55 @@
1
+ // Mastering: gentle bus glue and a look-ahead brickwall, peaks held under the ceiling without pumping.
2
+ import { SR, biquad, dbToGain } from "./dsp.ts";
3
+ import type { Bus } from "./dsp.ts";
4
+
5
+ // presence (the instruments are voiced dark, so the top is lifted broadly, never a narrow treble boost)
6
+ // and a little tape saturation. The filters keep per-channel memory, so each channel gets its own.
7
+ function eqChannel(ch: Float32Array): void {
8
+ const hp = biquad("hp", 32), lo = biquad("lowshelf", 110, 0.7, -1.5), hi = biquad("highshelf", 2200, 0.6, 5);
9
+ for (let i = 0; i < ch.length; i++) ch[i] = Math.tanh(1.15 * hi(lo(hp(ch[i])))) / Math.tanh(1.15);
10
+ }
11
+ // a soft knee on the loudest transients first, so the limiter barely has to work
12
+ function softKnee(ch: Float32Array, knee: number): void {
13
+ for (let i = 0; i < ch.length; i++) {
14
+ const x = ch[i], a = Math.abs(x);
15
+ if (a > knee) ch[i] = Math.sign(x) * (knee + (1 - knee) * Math.tanh((a - knee) / (1 - knee)));
16
+ }
17
+ }
18
+ // sample peaks and the half-sample peaks between them (cubic interpolation ~ true peak)
19
+ const half = (x: Float32Array, i: number): number => (-(x[i - 1] || 0) + 9 * x[i] + 9 * (x[i + 1] || 0) - (x[i + 2] || 0)) / 16;
20
+ const peakAmp = (bus: Bus): Float32Array =>
21
+ Float32Array.from({ length: bus.n }, (_, i) => Math.max(Math.abs(bus.L[i]), Math.abs(bus.R[i]), Math.abs(half(bus.L, i)), Math.abs(half(bus.R, i))));
22
+
23
+ // the exact max over the next `look` samples (monotonic queue); kept as one loop, the queue is the mechanism
24
+ function wantedGain(amp: Float32Array, look: number, ceil: number): Float32Array {
25
+ const n = amp.length, want = new Float32Array(n), q = new Int32Array(n);
26
+ let h = 0, t = 0;
27
+ for (let i = 0, j = 0; i < n; i++) {
28
+ for (; j < Math.min(n, i + look); j++) { while (t > h && amp[q[t - 1]] <= amp[j]) t--; q[t++] = j; }
29
+ while (q[h] < i) h++;
30
+ const pk = amp[q[h]];
31
+ want[i] = pk > ceil ? ceil / pk : 1;
32
+ }
33
+ return want;
34
+ }
35
+ // gain glides down before a peak arrives (attack over the look-ahead) and recovers over ~120 ms
36
+ function applyGlide(bus: Bus, want: Float32Array): void {
37
+ const rel = Math.exp(-1 / (0.12 * SR));
38
+ let g = 1;
39
+ for (let i = 0; i < bus.n; i++) {
40
+ const w = want[i];
41
+ g = w < g ? w : w - (w - g) * rel;
42
+ bus.L[i] *= g; bus.R[i] *= g;
43
+ }
44
+ }
45
+ const hardClip = (ch: Float32Array, ceil: number): void => {
46
+ for (let i = 0; i < ch.length; i++) { if (ch[i] > ceil) ch[i] = ceil; else if (ch[i] < -ceil) ch[i] = -ceil; }
47
+ };
48
+
49
+ export function master(bus: Bus, eq = true): void {
50
+ if (eq) { eqChannel(bus.L); eqChannel(bus.R); }
51
+ const knee = dbToGain(-6), look = Math.round(0.005 * SR), ceil = dbToGain(-2.3);
52
+ softKnee(bus.L, knee); softKnee(bus.R, knee);
53
+ applyGlide(bus, wantedGain(peakAmp(bus), look, ceil));
54
+ hardClip(bus.L, ceil); hardClip(bus.R, ceil);
55
+ }
@@ -0,0 +1,119 @@
1
+ // audio: the soundtrack, composed and mixed offline for each video.
2
+ // A music bed in the style's genre, in tempo, warm and low (played instruments, not beeps), one stereo mix,
3
+ // -16 LUFS. Pure JS synthesis (no samples), seeded so a rebuild sounds identical. ffmpeg measures loudness.
4
+ // This file is the edge: ffmpeg reads and loudness, WAV writes, the mastering loop.
5
+ import fs from "node:fs";
6
+ import { spawnSync } from "node:child_process";
7
+ import { PRESETS, composeMusic } from "./compose.ts";
8
+ import type { Preset } from "./compose.ts";
9
+ import { SR, busGain, busMixFrom, clamp, dbToGain, makeBus, rng } from "./dsp.ts";
10
+ import type { Bus, Rng } from "./dsp.ts";
11
+ import { master } from "./master.ts";
12
+
13
+ export { SR } from "./dsp.ts";
14
+ export { PRESETS } from "./compose.ts";
15
+
16
+ // `cues` (sound effects) is accepted for old callers and ignored.
17
+ export type Plan = {
18
+ duration: number;
19
+ style?: string;
20
+ preset?: string;
21
+ bpm?: number;
22
+ boundaries?: readonly number[];
23
+ outroAt?: number;
24
+ cues?: readonly unknown[];
25
+ music?: string; // "auto" | "none" | a file path
26
+ musicVolume?: number;
27
+ musicStart?: number;
28
+ };
29
+ export type Report = {
30
+ music: string;
31
+ preset: string | undefined;
32
+ bpm: number;
33
+ sfx: number;
34
+ lufs: number | null;
35
+ truePeak: number | null;
36
+ };
37
+ type Loud = { readonly I: number; readonly TP: number };
38
+
39
+ // ---------- ffmpeg and files ----------
40
+ function writeWav(file: string, bus: Bus, seconds: number): void {
41
+ const n = Math.min(bus.n, Math.round(seconds * SR));
42
+ const b = Buffer.alloc(44 + n * 4);
43
+ b.write("RIFF", 0); b.writeUInt32LE(36 + n * 4, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
44
+ b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(n * 4, 40);
45
+ for (let i = 0; i < n; i++) { b.writeInt16LE(Math.round(clamp(bus.L[i], -1, 1) * 32767), 44 + i * 4); b.writeInt16LE(Math.round(clamp(bus.R[i], -1, 1) * 32767), 46 + i * 4); }
46
+ fs.writeFileSync(file, b);
47
+ }
48
+ function readAudio(file: string, seconds: number): Bus | null {
49
+ const r = spawnSync("ffmpeg", ["-v", "error", "-i", file, "-t", String(seconds), "-f", "f32le", "-ac", "2", "-ar", String(SR), "-"], { maxBuffer: 1 << 30 });
50
+ if (r.status !== 0) return null;
51
+ const f = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, Math.floor(r.stdout.length / 4));
52
+ const bus = makeBus(f.length / 2 / SR);
53
+ for (let i = 0; i < bus.n; i++) { bus.L[i] = f[i * 2]; bus.R[i] = f[i * 2 + 1]; }
54
+ return bus;
55
+ }
56
+ function loudness(file: string): Loud | null {
57
+ const r = spawnSync("ffmpeg", ["-hide_banner", "-i", file, "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], { encoding: "utf8" });
58
+ const m = /\{[\s\S]*\}/.exec(r.stderr || "");
59
+ if (!m) return null;
60
+ try {
61
+ const j: unknown = JSON.parse(m[0]);
62
+ if (typeof j !== "object" || j === null) return null;
63
+ return { I: "input_i" in j ? Number(j.input_i) : NaN, TP: "input_tp" in j ? Number(j.input_tp) : NaN };
64
+ } catch { return null; }
65
+ }
66
+
67
+ // ---------- the bed ----------
68
+ const presetFor = (plan: Plan): Preset => {
69
+ const pick = (name: string | undefined): Preset | undefined => (name !== undefined && Object.hasOwn(PRESETS, name) ? PRESETS[name] : undefined);
70
+ const base = pick(plan.preset) ?? pick(plan.style) ?? PRESETS.keynote;
71
+ return plan.bpm ? { ...base, bpm: plan.bpm } : base;
72
+ };
73
+ // your own track, trimmed to the video, faded in and out under the volume
74
+ function addOwnTrack(mix: Bus, plan: Plan, file: string): void {
75
+ const m = readAudio(file, plan.duration + (plan.musicStart || 0) + 3);
76
+ if (!m) return;
77
+ const off = Math.round((plan.musicStart || 0) * SR), fo = Math.round(Math.min(2, plan.duration * 0.1) * SR), end = Math.round(plan.duration * SR);
78
+ for (let i = 0; i < end && i + off < m.n; i++) {
79
+ const g = Math.min(1, i / (0.25 * SR)) * (i > end - fo ? (end - i) / fo : 1) * (plan.musicVolume ?? 0.8);
80
+ mix.L[i] += m.L[i + off] * g; mix.R[i] += m.R[i + off] * g;
81
+ }
82
+ }
83
+ function addMusic(mix: Bus, plan: Plan, P: Preset, r: Rng, ham: Rng): void {
84
+ if (plan.music === "auto" || plan.music == null) busMixFrom(mix, composeMusic(P, plan, r, ham), 0.9);
85
+ else if (plan.music && plan.music !== "none") addOwnTrack(mix, plan, plan.music);
86
+ }
87
+
88
+ // ---------- loudness loop ----------
89
+ // -16 LUFS integrated (full, not loud) with peaks under -2 dBTP (headroom for the AAC encode). A few passes of
90
+ // gain -> soft knee + limiter -> measure, so transient-heavy styles get there too.
91
+ function trimPeaks(mix: Bus, outFile: string, seconds: number, L2: Loud | null): Loud | null {
92
+ if (!L2 || L2.TP <= -1.95) return L2;
93
+ busGain(mix, dbToGain(-2.05 - L2.TP));
94
+ writeWav(outFile, mix, seconds);
95
+ return loudness(outFile);
96
+ }
97
+ function loudnessPass(mix: Bus, outFile: string, seconds: number, L2: Loud | null, pass: number): Loud | null {
98
+ if (pass >= 4 || !L2 || !isFinite(L2.I)) return L2;
99
+ const g = clamp(-16 - L2.I, -20, 20);
100
+ if (Math.abs(g) < 0.25 && L2.TP <= -1.95) return L2;
101
+ busGain(mix, dbToGain(g));
102
+ master(mix, false);
103
+ writeWav(outFile, mix, seconds);
104
+ return loudnessPass(mix, outFile, seconds, trimPeaks(mix, outFile, seconds, loudness(outFile)), pass + 1);
105
+ }
106
+
107
+ export function renderSoundtrack(plan: Plan, outFile: string, { seed = 7 }: { seed?: number } = {}): Report {
108
+ const r = rng(seed), ham = rng(seed + 1), P = presetFor(plan);
109
+ const seconds = plan.duration + 0.05;
110
+ const mix = makeBus(plan.duration + 3);
111
+ addMusic(mix, plan, P, r, ham);
112
+ master(mix);
113
+ const tmp = outFile.replace(/\.wav$/, ".pre.wav");
114
+ writeWav(tmp, mix, seconds);
115
+ const L2 = loudnessPass(mix, outFile, seconds, loudness(tmp), 0);
116
+ if (!fs.existsSync(outFile)) writeWav(outFile, mix, seconds);
117
+ fs.rmSync(tmp, { force: true });
118
+ return { music: plan.music || "auto", preset: plan.preset || plan.style, bpm: P.bpm, sfx: 0, lufs: L2 ? L2.I : null, truePeak: L2 ? L2.TP : null };
119
+ }
@@ -0,0 +1,35 @@
1
+ import type { Line } from "./types.ts";
2
+
3
+ // "*" and "|" are emphasis and pause marks for the writer; they are never spoken or shown.
4
+ export const words = (s: string): readonly string[] => s.replace(/\*|\|/g, "").split(/\s+/).filter(Boolean);
5
+
6
+ const STOP = /[,.;:!?…\u2014]$/;
7
+
8
+ /** How many words the next chunk takes: an even share, nudged one word to land on punctuation. */
9
+ function chunkSize(w: readonly string[], n: number, i: number, parts: number): number {
10
+ const left = w.length - i;
11
+ const even = Math.round(left / parts);
12
+ const nudge = [0, 1, -1].find((d) => {
13
+ const size = even + d;
14
+ return size >= 2 && left - size >= 2 && size <= n && STOP.test(w[i + size - 1] ?? "");
15
+ });
16
+ return even + (nudge ?? 0);
17
+ }
18
+
19
+ function split(w: readonly string[], n: number, i: number, parts: number): readonly string[] {
20
+ if (parts <= 1) return [w.slice(i).join(" ")];
21
+ const size = chunkSize(w, n, i, parts);
22
+ return [w.slice(i, i + size).join(" "), ...split(w, n, i + size, parts - 1)];
23
+ }
24
+
25
+ /** Balanced chunks of at most n words (7 words become 4 + 3, not 5 + 2). */
26
+ export function chunks(text: string, n: number): readonly string[] {
27
+ const w = words(text);
28
+ return w.length <= n ? [w.join(" ")] : split(w, n, 0, Math.ceil(w.length / n));
29
+ }
30
+
31
+ /** A "/" in a line's caption forces a break there; in spoken text it is just a character ("and/or"). */
32
+ export function captionChunks(line: Line, n: number): readonly string[] {
33
+ if (line.caption?.includes("/")) return line.caption.split("/").map((x) => x.trim()).filter(Boolean);
34
+ return chunks(line.caption ?? line.say, n);
35
+ }