kasaframe 0.1.4 → 0.1.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.
- package/NOTICE +311 -0
- package/README.md +18 -5
- package/docs/AGENTS.md +2 -1
- package/package.json +1 -1
- package/packages/adapters/src/project.ts +2 -0
- package/packages/check/package.json +1 -0
- package/packages/check/src/check.ts +8 -2
- package/packages/check/src/rules/frames.ts +4 -5
- package/packages/cli/src/commands/doctor.ts +15 -2
- package/packages/cli/src/commands/fonts.ts +118 -0
- package/packages/cli/src/commands/make.ts +15 -5
- package/packages/cli/src/commands/render.ts +34 -7
- package/packages/cli/src/commands/update.ts +66 -0
- package/packages/cli/src/fonts.ts +405 -0
- package/packages/cli/src/kf.ts +5 -0
- package/packages/cli/src/mix.ts +66 -4
- package/packages/cli/src/segments.ts +1 -1
- package/packages/cli/src/sfx.ts +429 -0
- package/packages/cli/src/update.ts +250 -0
- package/packages/core/src/frame.ts +7 -4
- package/packages/core/src/index.ts +3 -1
- package/packages/core/src/project.ts +19 -0
- package/packages/core/src/sfx.ts +70 -0
- package/packages/director/src/assemble.ts +7 -4
- package/packages/director/src/index.ts +1 -1
- package/packages/director/src/pattern.ts +2 -0
- package/packages/director/src/scene.ts +14 -5
- package/packages/director/src/transitions.ts +21 -8
- package/packages/motion/src/enter.ts +7 -1
- package/packages/motion/src/impact.ts +5 -2
- package/packages/motion/src/moves/dress.ts +15 -2
- package/packages/motion/src/moves/menu.ts +11 -5
- package/packages/motion/src/transition.ts +3 -1
- package/packages/motion/src/transition3d/index.ts +3 -1
- package/packages/motion/src/transition3d/live.ts +5 -3
- package/packages/patterns/src/bars/rank.ts +6 -7
- package/packages/patterns/src/before-after/shrink.ts +3 -4
- package/packages/patterns/src/before-after/strike.ts +4 -5
- package/packages/patterns/src/code/terminal.ts +25 -16
- package/packages/patterns/src/end/lockup.ts +13 -10
- package/packages/patterns/src/end/slam.ts +7 -5
- package/packages/patterns/src/hook-number/counter.ts +15 -9
- package/packages/patterns/src/hook-number/slam.ts +6 -8
- package/packages/patterns/src/kit.ts +281 -58
- package/packages/patterns/src/limit/gauge.ts +16 -11
- package/packages/patterns/src/quote/karaoke.ts +17 -12
- package/packages/patterns/src/sfx.ts +80 -0
- package/packages/patterns/src/term-reveal/chips.ts +24 -10
- package/packages/patterns/src/term-reveal/expand.ts +17 -13
- package/packages/patterns/src/title/mask.ts +9 -8
- package/packages/patterns/src/title/slam.ts +1 -1
- package/packages/patterns/src/types.ts +1 -0
- package/packages/plan/src/asks.ts +1 -1
- package/packages/plan/src/brief.ts +5 -2
- package/packages/plan/src/themes.ts +8 -2
- package/packages/themes/kit/THEME-MAKE.md +2 -2
- package/packages/themes/kit/fonts/display.json +3517 -0
- package/packages/themes/kit/fonts/sans.json +5155 -0
- package/packages/themes/kit/fonts/serif.json +5229 -0
- package/packages/themes/kit/fonts.json +243 -79
- package/packages/themes/package.json +3 -1
- package/packages/themes/src/fontfile.ts +183 -0
- package/packages/themes/src/fonts.ts +296 -0
- package/packages/themes/src/index.ts +32 -2
- package/packages/themes/src/look.ts +34 -8
- package/packages/themes/src/slots.ts +32 -2
- package/packages/themes/src/types.ts +33 -0
- package/packages/themes/src/vary.ts +35 -34
- package/presets/kawaii-pastel.json +2 -2
- package/sfx/README.md +82 -0
- package/sfx/click/appear.wav +0 -0
- package/sfx/click/burst.wav +0 -0
- package/sfx/click/drop.wav +0 -0
- package/sfx/click/end.wav +0 -0
- package/sfx/click/hop.wav +0 -0
- package/sfx/click/land.wav +0 -0
- package/sfx/click/select.wav +0 -0
- package/sfx/click/stick.wav +0 -0
- package/sfx/click/tick.wav +0 -0
- package/sfx/click/type.wav +0 -0
- package/sfx/click/whoosh.wav +0 -0
- package/sfx/pop/appear.wav +0 -0
- package/sfx/pop/burst.wav +0 -0
- package/sfx/pop/drop.wav +0 -0
- package/sfx/pop/end.wav +0 -0
- package/sfx/pop/hop.wav +0 -0
- package/sfx/pop/land.wav +0 -0
- package/sfx/pop/select.wav +0 -0
- package/sfx/pop/stick.wav +0 -0
- package/sfx/pop/tick.wav +0 -0
- package/sfx/pop/type.wav +0 -0
- package/sfx/pop/whoosh.wav +0 -0
- package/sfx/tags.json +545 -0
- package/sfx/ui/appear.wav +0 -0
- package/sfx/ui/burst.wav +0 -0
- package/sfx/ui/drop.wav +0 -0
- package/sfx/ui/end.wav +0 -0
- package/sfx/ui/hop.wav +0 -0
- package/sfx/ui/land.wav +0 -0
- package/sfx/ui/select.wav +0 -0
- package/sfx/ui/stick.wav +0 -0
- package/sfx/ui/tick.wav +0 -0
- package/sfx/ui/type.wav +0 -0
- package/sfx/ui/whoosh.wav +0 -0
- package/skills/kasaframe/SKILL.md +1 -0
- package/themes/blueprint/sample/style.json +5 -5
- package/themes/blueprint/style.json +5 -5
- package/themes/blueprint/theme.json +3 -1
- package/themes/blueprint/variants/deep-rose-suit.json +1 -1
- package/themes/blueprint/variants/navy-coral-2.json +1 -1
- package/themes/blueprint/variants/navy-coral-suit.json +1 -1
- package/themes/blueprint/variants/navy-orange-2.json +1 -1
- package/themes/blueprint/variants/navy-orange-suit.json +1 -1
- package/themes/cartography/sample/style.json +2 -2
- package/themes/cartography/style.json +2 -2
- package/themes/cartography/theme.json +3 -1
- package/themes/cartography/variants/deep-plum.json +1 -1
- package/themes/cartography/variants/frost-teal.json +1 -1
- package/themes/cartography/variants/navy-green.json +1 -1
- package/themes/circuit-board/sample/style.json +2 -2
- package/themes/circuit-board/style.json +2 -2
- package/themes/circuit-board/theme.json +3 -1
- package/themes/circuit-board/variants/dusk-gold-pretendard.json +1 -1
- package/themes/circuit-board/variants/navy-gold-grain.json +1 -1
- package/themes/circuit-board/variants/wine-gold-pretendard.json +1 -1
- package/themes/clay-toy/sample/style.json +2 -2
- package/themes/clay-toy/style.json +2 -2
- package/themes/clay-toy/theme.json +3 -1
- package/themes/clay-toy/variants/blush-coral-grain.json +1 -1
- package/themes/clay-toy/variants/chalk-teal.json +1 -1
- package/themes/clay-toy/variants/frost-sky.json +1 -1
- package/themes/clay-toy/variants/frost-teal.json +1 -1
- package/themes/clay-toy/variants/lilac-plum-round.json +1 -1
- package/themes/clay-toy/variants/lilac-plum.json +1 -1
- package/themes/clay-toy/variants/sage-indigo.json +1 -1
- package/themes/conveyor-factory/sample/style.json +3 -3
- package/themes/conveyor-factory/style.json +3 -3
- package/themes/conveyor-factory/theme.json +3 -1
- package/themes/conveyor-factory/variants/cocoa-gold.json +1 -1
- package/themes/conveyor-factory/variants/navy-gold.json +1 -1
- package/themes/conveyor-factory/variants/slate-amber-2.json +1 -1
- package/themes/conveyor-factory/variants/slate-amber-suit.json +1 -1
- package/themes/conveyor-factory/variants/slate-gold-bold.json +1 -1
- package/themes/data-journal/theme.json +3 -1
- package/themes/data-journal/variants/deep-coral.json +1 -1
- package/themes/data-journal/variants/navy-lime.json +1 -1
- package/themes/data-journal/variants/wine-indigo.json +1 -1
- package/themes/debug-hud/sample/style.json +1 -1
- package/themes/debug-hud/style.json +1 -1
- package/themes/debug-hud/theme.json +2 -1
- package/themes/debug-hud/variants/cocoa-teal.json +2 -2
- package/themes/debug-hud/variants/deep-teal-scan.json +2 -2
- package/themes/debug-hud/variants/dusk-plum.json +2 -2
- package/themes/debug-hud/variants/forest-lime.json +2 -2
- package/themes/debug-hud/variants/slate-green.json +2 -2
- package/themes/debug-hud/variants/wine-gold.json +2 -2
- package/themes/depth-flight/sample/style.json +1 -1
- package/themes/depth-flight/style.json +1 -1
- package/themes/depth-flight/theme.json +3 -1
- package/themes/depth-flight/variants/cocoa-cyan.json +1 -1
- package/themes/depth-flight/variants/cocoa-violet.json +1 -1
- package/themes/depth-flight/variants/deep-sky.json +1 -1
- package/themes/depth-flight/variants/forest-blue.json +1 -1
- package/themes/depth-flight/variants/navy-sky-grain.json +1 -1
- package/themes/depth-flight/variants/navy-sky.json +1 -1
- package/themes/depth-flight/variants/night-teal.json +1 -1
- package/themes/depth-flight/variants/slate-indigo.json +1 -1
- package/themes/depth-flight/variants/slate-violet.json +1 -1
- package/themes/film-reel/sample/style.json +2 -2
- package/themes/film-reel/style.json +2 -2
- package/themes/film-reel/theme.json +3 -1
- package/themes/film-reel/variants/chalk-pink.json +1 -1
- package/themes/film-reel/variants/lilac-gold.json +1 -1
- package/themes/film-reel/variants/lilac-violet.json +1 -1
- package/themes/glass-light/sample/style.json +1 -1
- package/themes/glass-light/style.json +1 -1
- package/themes/glass-light/theme.json +3 -1
- package/themes/glass-light/variants/blush-pink.json +1 -1
- package/themes/glass-light/variants/blush-plum.json +1 -1
- package/themes/glass-light/variants/cream-violet.json +1 -1
- package/themes/glass-light/variants/frost-sky-suit.json +1 -1
- package/themes/glass-light/variants/frost-sky.json +1 -1
- package/themes/glass-light/variants/sage-green.json +1 -1
- package/themes/ink-wash/sample/style.json +3 -3
- package/themes/ink-wash/style.json +3 -3
- package/themes/ink-wash/theme.json +3 -1
- package/themes/iso-diorama/sample/style.json +1 -1
- package/themes/iso-diorama/style.json +1 -1
- package/themes/iso-diorama/theme.json +3 -1
- package/themes/iso-diorama/variants/chalk-plum.json +1 -1
- package/themes/iso-diorama/variants/cocoa-indigo.json +1 -1
- package/themes/iso-diorama/variants/dusk-blue.json +1 -1
- package/themes/iso-diorama/variants/dusk-coral.json +1 -1
- package/themes/iso-diorama/variants/dusk-jade.json +1 -1
- package/themes/iso-diorama/variants/forest-cyan.json +1 -1
- package/themes/iso-diorama/variants/frost-green.json +1 -1
- package/themes/iso-diorama/variants/frost-teal.json +1 -1
- package/themes/iso-diorama/variants/navy-amber.json +1 -1
- package/themes/isotype/sample/style.json +2 -2
- package/themes/isotype/style.json +2 -2
- package/themes/isotype/theme.json +2 -1
- package/themes/isotype/variants/blush-rose.json +1 -1
- package/themes/isotype/variants/chalk-coral.json +1 -1
- package/themes/isotype/variants/chalk-orange.json +1 -1
- package/themes/isotype/variants/frost-amber.json +1 -1
- package/themes/isotype/variants/frost-coral.json +1 -1
- package/themes/isotype/variants/frost-orange.json +1 -1
- package/themes/isotype/variants/sand-amber.json +1 -1
- package/themes/kawaii-sticker/effects.ts +15 -3
- package/themes/kawaii-sticker/sample/style.json +2 -2
- package/themes/kawaii-sticker/style.json +2 -2
- package/themes/kawaii-sticker/theme.json +4 -1
- package/themes/kawaii-sticker/variants/cream-gold.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-green.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-indigo.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-sky.json +1 -1
- package/themes/kawaii-sticker/variants/dusk-teal.json +1 -1
- package/themes/kawaii-sticker/variants/frost-blue.json +1 -1
- package/themes/kawaii-sticker/variants/frost-plum.json +1 -1
- package/themes/kawaii-sticker/variants/lilac-violet.json +1 -1
- package/themes/kawaii-sticker/variants/navy-gold.json +1 -1
- package/themes/kawaii-sticker/variants/navy-plum.json +1 -1
- package/themes/lab-notebook/sample/style.json +3 -3
- package/themes/lab-notebook/style.json +3 -3
- package/themes/lab-notebook/theme.json +2 -1
- package/themes/lab-notebook/variants/blush-orange.json +1 -1
- package/themes/lab-notebook/variants/chalk-coral.json +1 -1
- package/themes/lab-notebook/variants/chalk-orange.json +1 -1
- package/themes/lab-notebook/variants/chalk-rose.json +1 -1
- package/themes/lab-notebook/variants/lilac-rose.json +1 -1
- package/themes/lab-notebook/variants/sage-rose.json +1 -1
- package/themes/lab-notebook/variants/sand-orange.json +1 -1
- package/themes/lab-notebook/variants/snow-orange.json +1 -1
- package/themes/lab-notebook/variants/snow-rose.json +1 -1
- package/themes/manga-panel/sample/style.json +3 -3
- package/themes/manga-panel/style.json +3 -3
- package/themes/manga-panel/theme.json +3 -1
- package/themes/manga-panel/variants/chalk-cyan-dots.json +1 -1
- package/themes/manga-panel/variants/chalk-rose.json +1 -1
- package/themes/manga-panel/variants/snow-cyan.json +1 -1
- package/themes/manga-panel/variants/snow-magenta-dots.json +1 -1
- package/themes/marble-run/sample/style.json +2 -2
- package/themes/marble-run/style.json +2 -2
- package/themes/marble-run/theme.json +3 -1
- package/themes/marble-run/variants/slate-orange.json +1 -1
- package/themes/neon-sign/sample/style.json +2 -2
- package/themes/neon-sign/style.json +2 -2
- package/themes/neon-sign/theme.json +2 -1
- package/themes/neon-sign/variants/dusk-plum.json +1 -1
- package/themes/neon-sign/variants/forest-magenta.json +1 -1
- package/themes/neon-sign/variants/night-pink.json +1 -1
- package/themes/neon-sign/variants/night-violet.json +1 -1
- package/themes/neon-sign/variants/slate-violet.json +1 -1
- package/themes/newspaper/sample/style.json +2 -2
- package/themes/newspaper/style.json +2 -2
- package/themes/newspaper/theme.json +2 -1
- package/themes/newspaper/variants/blush-amber.json +1 -1
- package/themes/newspaper/variants/chalk-magenta.json +1 -1
- package/themes/newspaper/variants/frost-sky.json +1 -1
- package/themes/newspaper/variants/lilac-green.json +1 -1
- package/themes/newspaper/variants/sage-lime.json +1 -1
- package/themes/og-measure/THEME.md +41 -6
- package/themes/og-measure/effects.ts +236 -115
- package/themes/og-measure/sample/assets/brand/og-logo.svg +11 -11
- package/themes/og-measure/sample/lib/data.ts +6 -0
- package/themes/og-measure/sample/scenes/end.ts +14 -10
- package/themes/og-measure/sample/scenes/gather.ts +4 -1
- package/themes/og-measure/sample/scenes/models.ts +5 -2
- package/themes/og-measure/sample/scenes/one.ts +9 -6
- package/themes/og-measure/sample/scenes/price.ts +8 -3
- package/themes/og-measure/sample/style.json +30 -35
- package/themes/og-measure/slots.ts +54 -30
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/style.json +30 -35
- package/themes/og-measure/theme.json +72 -49
- package/themes/og-measure/variants/snow-blue.json +90 -0
- package/themes/orbit-system/sample/style.json +3 -3
- package/themes/orbit-system/style.json +3 -3
- package/themes/orbit-system/theme.json +3 -1
- package/themes/orbit-system/variants/cocoa-amber.json +1 -1
- package/themes/orbit-system/variants/cocoa-cyan.json +1 -1
- package/themes/orbit-system/variants/dusk-magenta.json +1 -1
- package/themes/orbit-system/variants/dusk-violet.json +1 -1
- package/themes/orbit-system/variants/forest-coral.json +1 -1
- package/themes/orbit-system/variants/wine-amber.json +1 -1
- package/themes/orbit-system/variants/wine-magenta.json +1 -1
- package/themes/origami-fold/sample/style.json +1 -1
- package/themes/origami-fold/style.json +1 -1
- package/themes/origami-fold/theme.json +3 -1
- package/themes/origami-fold/variants/deep-amber-sunflower.json +1 -1
- package/themes/origami-fold/variants/deep-orange.json +1 -1
- package/themes/origami-fold/variants/forest-coral.json +1 -1
- package/themes/origami-fold/variants/forest-pink.json +1 -1
- package/themes/origami-fold/variants/frost-amber-paper.json +1 -1
- package/themes/origami-fold/variants/sage-coral-sunflower.json +1 -1
- package/themes/origami-fold/variants/sage-coral.json +1 -1
- package/themes/origami-fold/variants/sage-pink-round.json +1 -1
- package/themes/origami-fold/variants/sage-pink.json +1 -1
- package/themes/paper-cutout/sample/style.json +2 -2
- package/themes/paper-cutout/style.json +2 -2
- package/themes/paper-cutout/theme.json +3 -1
- package/themes/paper-cutout/variants/blush-violet.json +1 -1
- package/themes/paper-cutout/variants/frost-lime.json +1 -1
- package/themes/paper-cutout/variants/lilac-gold.json +1 -1
- package/themes/paper-cutout/variants/sage-cyan.json +1 -1
- package/themes/paper-cutout/variants/sage-green.json +1 -1
- package/themes/paper-cutout/variants/sage-pink.json +1 -1
- package/themes/paper-cutout/variants/sand-blue.json +1 -1
- package/themes/pixel-quest/theme.json +2 -1
- package/themes/receipt/sample/style.json +2 -2
- package/themes/receipt/style.json +2 -2
- package/themes/riso-zine/sample/style.json +2 -2
- package/themes/riso-zine/style.json +2 -2
- package/themes/riso-zine/theme.json +3 -1
- package/themes/riso-zine/variants/chalk-lime.json +1 -1
- package/themes/riso-zine/variants/frost-green.json +1 -1
- package/themes/riso-zine/variants/lilac-plum.json +1 -1
- package/themes/riso-zine/variants/lilac-violet.json +1 -1
- package/themes/server-hall/sample/style.json +2 -2
- package/themes/server-hall/style.json +2 -2
- package/themes/server-hall/theme.json +3 -1
- package/themes/server-hall/variants/night-indigo.json +1 -1
- package/themes/server-hall/variants/wine-plum.json +1 -1
- package/themes/subway-map/sample/style.json +2 -2
- package/themes/subway-map/style.json +2 -2
- package/themes/subway-map/theme.json +3 -1
- package/themes/subway-map/variants/deep-green.json +1 -1
- package/themes/subway-map/variants/deep-teal.json +1 -1
- package/themes/subway-map/variants/frost-jade.json +1 -1
- package/themes/subway-map/variants/frost-teal.json +1 -1
- package/themes/subway-map/variants/slate-green.json +1 -1
- package/themes/subway-map/variants/snow-jade.json +1 -1
- package/themes/swiss-poster/sample/style.json +2 -2
- package/themes/swiss-poster/style.json +2 -2
- package/themes/swiss-poster/theme.json +3 -1
- package/themes/swiss-poster/variants/chalk-plum.json +1 -1
- package/themes/swiss-poster/variants/cocoa-cyan.json +1 -1
- package/themes/swiss-poster/variants/dusk-magenta.json +1 -1
- package/themes/swiss-poster/variants/frost-indigo.json +1 -1
- package/themes/swiss-poster/variants/slate-orange.json +1 -1
- package/themes/water-works/sample/style.json +1 -1
- package/themes/water-works/style.json +1 -1
- package/themes/water-works/theme.json +3 -1
- package/themes/water-works/variants/chalk-cyan.json +1 -1
- package/themes/water-works/variants/frost-indigo.json +1 -1
- package/themes/water-works/variants/sand-amber.json +1 -1
- package/themes/water-works/variants/sand-plum.json +1 -1
- package/themes/whiteboard/sample/style.json +2 -2
- package/themes/whiteboard/style.json +2 -2
- package/themes/whiteboard/theme.json +3 -1
- package/themes/whiteboard/variants/chalk-coral.json +1 -1
- package/themes/whiteboard/variants/cream-lime.json +1 -1
- package/themes/whiteboard/variants/frost-teal.json +1 -1
- package/themes/whiteboard/variants/lilac-indigo.json +1 -1
- package/themes/whiteboard/variants/sage-plum.json +1 -1
- package/themes/whiteboard/variants/snow-cyan.json +1 -1
- package/themes/whiteboard/variants/snow-green.json +1 -1
- package/themes/whiteboard/variants/snow-magenta.json +1 -1
- package/themes/og-measure/sample/assets/brand/og-logo-full.svg +0 -12
- package/themes/og-measure/variants/chalk-orange.json +0 -102
- package/themes/og-measure/variants/cocoa-amber.json +0 -138
- package/themes/og-measure/variants/cocoa-cyan.json +0 -138
- package/themes/og-measure/variants/cocoa-gold.json +0 -129
- package/themes/og-measure/variants/cream-lime.json +0 -137
- package/themes/og-measure/variants/deep-jade.json +0 -100
- package/themes/og-measure/variants/dusk-violet.json +0 -133
- package/themes/og-measure/variants/forest-cyan.json +0 -123
- package/themes/og-measure/variants/frost-amber.json +0 -129
- package/themes/og-measure/variants/frost-cyan.json +0 -133
- package/themes/og-measure/variants/lilac-plum.json +0 -127
- package/themes/og-measure/variants/lilac-violet.json +0 -139
- package/themes/og-measure/variants/sage-green.json +0 -137
- package/themes/og-measure/variants/wine-jade.json +0 -127
- package/themes/og-measure/variants/wine-plum.json +0 -127
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { SFX_ROLES, type SfxRole } from "@kasaframe/core";
|
|
5
|
+
import { loadTheme } from "@kasaframe/themes";
|
|
6
|
+
|
|
7
|
+
// 효과음 — 장면 움직임이 남긴 소리 자리(__kf.events 의 sfx)를 뜻 있는 것만 적게 고르고, 테마가 고른 결의 파일을 곡 둘레 크기에 맞춰 깔고
|
|
8
|
+
// 그 순간 곡을 짧게 눌러 자리를 낸다. 같은 자리·같은 설정이면 같은 소리 — 고르기·음높이 흔들기는 시각과 역할의 해시로만 정한다
|
|
9
|
+
|
|
10
|
+
const SR = 48000;
|
|
11
|
+
|
|
12
|
+
export interface SfxSettings {
|
|
13
|
+
/** 소리 결 — sfx/tags.json 의 sets */
|
|
14
|
+
set: string;
|
|
15
|
+
/** 둘레 곡(소리 자리 앞뒤 0.2초) 크기 대비 dB — 0 이면 곡과 비슷하게 */
|
|
16
|
+
db: number;
|
|
17
|
+
/** 1초에 몇 개까지(2초 창으로 센다 — 0.5 면 2초에 하나) */
|
|
18
|
+
perSec: number;
|
|
19
|
+
only: SfxRole[] | null;
|
|
20
|
+
/** 목소리가 있으면 더 낮춘다 */
|
|
21
|
+
voiceDb: number;
|
|
22
|
+
/** 잔소리(낱낱이 나타남·숫자 째깍·타자)도 깐다 — 기본은 뜻 있는 자리만 */
|
|
23
|
+
chatter: boolean;
|
|
24
|
+
/** 소리마다 곡을 이만큼(dB) 짧게 눌러 자리를 낸다 */
|
|
25
|
+
duck: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT: SfxSettings = { set: "ui", db: -2, perSec: 1, only: null, voiceDb: -4, chatter: false, duck: 3 };
|
|
29
|
+
|
|
30
|
+
// 역할마다 결 크기 위아래(db)·밀도가 넘칠 때 남기는 차례(prio 높은 것부터)·저역을 걷는 곳(Hz — 킥·베이스와 안 겹치게, 드롭 쿵만 낮게 둔다)
|
|
31
|
+
export const ROLE: Record<SfxRole, { db: number; prio: number; cut: number }> = {
|
|
32
|
+
drop: { db: 2, prio: 10, cut: 60 },
|
|
33
|
+
end: { db: 1, prio: 9, cut: 250 },
|
|
34
|
+
whoosh: { db: -1, prio: 8, cut: 250 },
|
|
35
|
+
land: { db: 0, prio: 7, cut: 150 },
|
|
36
|
+
stick: { db: 0, prio: 6, cut: 200 },
|
|
37
|
+
burst: { db: 0, prio: 6, cut: 250 },
|
|
38
|
+
hop: { db: -1, prio: 5, cut: 200 },
|
|
39
|
+
select: { db: -1, prio: 4, cut: 250 },
|
|
40
|
+
appear: { db: -2, prio: 3, cut: 250 },
|
|
41
|
+
tick: { db: -4, prio: 2, cut: 300 },
|
|
42
|
+
type: { db: -4, prio: 1, cut: 300 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 같은 역할은 이 안에 하나 */
|
|
46
|
+
const SAME_ROLE = 0.1;
|
|
47
|
+
/** 아무 소리끼리 이만큼 띄운다 — 붙어 나면 하나로 뭉개진다 */
|
|
48
|
+
const MIN_GAP = 0.25;
|
|
49
|
+
/** 드롭 앞뒤 이 안은 드롭만 */
|
|
50
|
+
const DROP_CLEAR = 0.35;
|
|
51
|
+
/** 밀도를 세는 창(초) */
|
|
52
|
+
const WINDOW = 2;
|
|
53
|
+
/** 가려짐 목표 — 소리 대역에서 소리 순간에 효과음이 곡보다 이만큼(dB) 위. 모자라면 BOOST 까지 더 키운다 */
|
|
54
|
+
const SMR_TARGET = 4;
|
|
55
|
+
const BOOST = 6;
|
|
56
|
+
/**
|
|
57
|
+
* 소리가 차지하는 대역 후보(Hz) — 소리마다 제 가장 센 대역에서 10dB 안쪽인 대역들(그 소리가 실제로 차지하는 곳)을 잡고, 그중 곡을 가장 넘는 대역에서
|
|
58
|
+
* 가려짐을 잰다(귀는 소리가 곡을 넘는 대역에서 그 소리를 듣는다). 낮은 몸에 2–6kHz 어택을 얹은 소리는 어택 대역이 잡힌다
|
|
59
|
+
*/
|
|
60
|
+
export const BANDS: readonly [number, number][] = [
|
|
61
|
+
[40, 150],
|
|
62
|
+
[150, 600],
|
|
63
|
+
[600, 2000],
|
|
64
|
+
[2000, 6000],
|
|
65
|
+
[6000, 12000],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const sfxDir = (): string => process.env.KF_SFX_DIR || fileURLToPath(new URL("../../../sfx/", import.meta.url));
|
|
69
|
+
|
|
70
|
+
interface BankFile {
|
|
71
|
+
path: string;
|
|
72
|
+
lead: number;
|
|
73
|
+
level: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SfxBank {
|
|
77
|
+
dir: string;
|
|
78
|
+
sets: string[];
|
|
79
|
+
file(set: string, role: SfxRole): BankFile | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadBank(dir = sfxDir()): SfxBank {
|
|
83
|
+
const tags = JSON.parse(readFileSync(join(dir, "tags.json"), "utf8")) as { sets: Record<string, unknown>; files: Record<string, { set: string; role: string; lead: number; level: number }> };
|
|
84
|
+
const by = new Map(Object.entries(tags.files).map(([rel, f]) => [`${f.set}/${f.role}`, { path: join(dir, rel), lead: f.lead, level: f.level }]));
|
|
85
|
+
return { dir, sets: Object.keys(tags.sets), file: (set, role) => by.get(`${set}/${role}`) ?? null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isRole = (r: unknown): r is SfxRole => (SFX_ROLES as readonly unknown[]).includes(r);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 이 프로젝트의 효과음 설정 — 기본 ← theme.json sfx ← kf.json sfx. 끄면 null.
|
|
92
|
+
* flag 는 kf render|make --sfx: off 면 끈다, on 이면 kf.json sfx: false 도 이 판만 켠다
|
|
93
|
+
*/
|
|
94
|
+
export function sfxSettings(dir: string, flag?: string): SfxSettings | null {
|
|
95
|
+
if (flag === "off") return null;
|
|
96
|
+
type Loose = Partial<Omit<SfxSettings, "only">> & { only?: string[] | null };
|
|
97
|
+
const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { theme?: string; sfx?: false | Loose };
|
|
98
|
+
if (kf.sfx === false && flag !== "on") return null;
|
|
99
|
+
let theme: Loose = {};
|
|
100
|
+
if (kf.theme) {
|
|
101
|
+
try {
|
|
102
|
+
theme = loadTheme(kf.theme).sfx ?? {};
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
const over = kf.sfx && typeof kf.sfx === "object" ? kf.sfx : {};
|
|
106
|
+
const s: Loose & Omit<SfxSettings, "only"> = { ...DEFAULT, ...theme, ...over };
|
|
107
|
+
const only = s.only ? s.only.filter(isRole) : null;
|
|
108
|
+
return {
|
|
109
|
+
set: s.set,
|
|
110
|
+
db: Number(s.db),
|
|
111
|
+
perSec: Math.max(0.1, Number(s.perSec)),
|
|
112
|
+
only: only?.length ? only : null,
|
|
113
|
+
voiceDb: Number(s.voiceDb),
|
|
114
|
+
chatter: !!s.chatter,
|
|
115
|
+
duck: Math.max(0, Number(s.duck)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SfxCue {
|
|
120
|
+
t: number;
|
|
121
|
+
role: SfxRole;
|
|
122
|
+
source: string;
|
|
123
|
+
weight: number;
|
|
124
|
+
/** 잔소리 — chatter 가 아니면 안 깐다 */
|
|
125
|
+
minor?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 페이지가 남긴 기록에서 소리 자리를, 곡의 드롭(beats.json — 영상 0초 기준)은 드롭 쿵으로 */
|
|
129
|
+
export function cuesOf(events: readonly { t: number; kind: string; source: string; name?: string; value?: number; minor?: boolean }[], drops: readonly number[] = []): SfxCue[] {
|
|
130
|
+
const out: SfxCue[] = [];
|
|
131
|
+
for (const e of events)
|
|
132
|
+
if (e.kind === "sfx" && isRole(e.name)) out.push({ t: e.t, role: e.name, source: e.source, weight: Math.min(1, Math.max(0.05, e.value ?? 1)), ...(e.minor ? { minor: true } : {}) });
|
|
133
|
+
for (const d of drops) out.push({ t: Math.round(d * 1000) / 1000, role: "drop", source: "beats", weight: 1 });
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function projectDrops(dir: string): number[] {
|
|
138
|
+
try {
|
|
139
|
+
const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { beats?: string };
|
|
140
|
+
if (!kf.beats) return [];
|
|
141
|
+
return (JSON.parse(readFileSync(resolve(dir, kf.beats), "utf8")) as { drops?: number[] }).drops ?? [];
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface SfxHit {
|
|
148
|
+
t: number;
|
|
149
|
+
role: SfxRole;
|
|
150
|
+
/** 결 안 역할 파일 — 섞을 때 bank 에서 찾는다 */
|
|
151
|
+
set: string;
|
|
152
|
+
/** 역할 크기 + 무게 + 흔들기(dB) */
|
|
153
|
+
db: number;
|
|
154
|
+
/** 음높이 배수 — 같은 소리가 줄줄이 나도 기계음처럼 안 들리게 */
|
|
155
|
+
rate: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 시각·역할 → 0..1 두 개(fnv1a). 같은 자리면 어느 기계에서나 같은 흔들기
|
|
159
|
+
function hash2(key: string): [number, number] {
|
|
160
|
+
let h = 0x811c9dc5;
|
|
161
|
+
for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193) >>> 0;
|
|
162
|
+
const a = (h & 0xffff) / 0x10000;
|
|
163
|
+
const b = (h >>> 16) / 0x10000;
|
|
164
|
+
return [a, b];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 소리 자리를 깔 소리로 — 잔소리(chatter 가 아니면)·결에 없는 역할·only 밖 역할은 빼고, 같은 역할은 SAME_ROLE 안에 하나, 차례(ROLE.prio)대로
|
|
169
|
+
* 드롭 앞뒤를 비우고 MIN_GAP 을 띄우고 아무 2초 창에 perSec×2 개(적어도 하나)까지 남긴다. 결과는 시각 차례
|
|
170
|
+
*/
|
|
171
|
+
export function placeSfx(cues: readonly SfxCue[], s: SfxSettings, bank: Pick<SfxBank, "file">, o: { from: number; to: number }): SfxHit[] {
|
|
172
|
+
const ok = cues
|
|
173
|
+
.filter((c) => c.t >= o.from - 1e-9 && c.t < o.to - 1e-9 && (s.chatter || !c.minor) && (!s.only || s.only.includes(c.role)) && bank.file(s.set, c.role))
|
|
174
|
+
.sort((a, b) => a.t - b.t || ROLE[b.role].prio - ROLE[a.role].prio || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
|
|
175
|
+
const merged: SfxCue[] = [];
|
|
176
|
+
const last = new Map<SfxRole, SfxCue>();
|
|
177
|
+
for (const c of ok) {
|
|
178
|
+
const prev = last.get(c.role);
|
|
179
|
+
if (prev && c.t - prev.t < SAME_ROLE) {
|
|
180
|
+
prev.weight = Math.max(prev.weight, c.weight);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const m = { ...c };
|
|
184
|
+
merged.push(m);
|
|
185
|
+
last.set(c.role, m);
|
|
186
|
+
}
|
|
187
|
+
const order = [...merged].sort((a, b) => ROLE[b.role].prio - ROLE[a.role].prio || a.t - b.t);
|
|
188
|
+
const kept: SfxCue[] = [];
|
|
189
|
+
const most = Math.max(1, Math.round(s.perSec * WINDOW));
|
|
190
|
+
// t 를 품은 어느 창 [w, w+WINDOW) 이 most 를 넘나 — 가장 붐비는 창은 t 앞 WINDOW 안의 소리(t 포함)에서 시작한다
|
|
191
|
+
const crowded = (t: number) => {
|
|
192
|
+
const xs = [...kept.map((k) => k.t), t];
|
|
193
|
+
return xs.filter((w) => w <= t && w > t - WINDOW).some((w) => xs.filter((x) => x >= w && x < w + WINDOW).length > most);
|
|
194
|
+
};
|
|
195
|
+
for (const c of order) {
|
|
196
|
+
if (kept.some((k) => Math.abs(k.t - c.t) < MIN_GAP)) continue;
|
|
197
|
+
if (c.role !== "drop" && kept.some((k) => k.role === "drop" && Math.abs(k.t - c.t) < DROP_CLEAR)) continue;
|
|
198
|
+
if (crowded(c.t)) continue;
|
|
199
|
+
kept.push(c);
|
|
200
|
+
}
|
|
201
|
+
return kept
|
|
202
|
+
.sort((a, b) => a.t - b.t)
|
|
203
|
+
.map((c) => {
|
|
204
|
+
const [h1, h2] = hash2(`${c.role}|${c.t.toFixed(3)}`);
|
|
205
|
+
return { t: c.t, role: c.role, set: s.set, db: Math.round((ROLE[c.role].db + 20 * Math.log10(c.weight) + (h1 - 0.5) * 2) * 100) / 100, rate: Math.round((1 + (h2 - 0.5) * 0.06) * 10000) / 10000 };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- 소리 다루기: 2차 버터워스 거르개(RBJ) — 같은 입력이면 같은 출력
|
|
210
|
+
|
|
211
|
+
type Biquad = [number, number, number, number, number];
|
|
212
|
+
|
|
213
|
+
function biquad(kind: "lp" | "hp", f: number): Biquad {
|
|
214
|
+
const w = (2 * Math.PI * f) / SR;
|
|
215
|
+
const c = Math.cos(w);
|
|
216
|
+
const a = Math.sin(w) / Math.SQRT2;
|
|
217
|
+
const b0 = kind === "lp" ? (1 - c) / 2 : (1 + c) / 2;
|
|
218
|
+
const b1 = kind === "lp" ? 1 - c : -(1 + c);
|
|
219
|
+
const a0 = 1 + a;
|
|
220
|
+
return [b0 / a0, b1 / a0, b0 / a0, (-2 * c) / a0, (1 - a) / a0];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function filter(x: Float32Array, q: Biquad): Float32Array {
|
|
224
|
+
const [b0, b1, b2, a1, a2] = q;
|
|
225
|
+
const y = new Float32Array(x.length);
|
|
226
|
+
let x1 = 0;
|
|
227
|
+
let x2 = 0;
|
|
228
|
+
let y1 = 0;
|
|
229
|
+
let y2 = 0;
|
|
230
|
+
for (let i = 0; i < x.length; i++) {
|
|
231
|
+
const v = b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
|
232
|
+
x2 = x1;
|
|
233
|
+
x1 = x[i];
|
|
234
|
+
y2 = y1;
|
|
235
|
+
y1 = v;
|
|
236
|
+
y[i] = v;
|
|
237
|
+
}
|
|
238
|
+
return y;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 대역 [lo, hi] 만 — 높은·낮은 거르개를 두 번씩(4차) */
|
|
242
|
+
function band(x: Float32Array, [lo, hi]: readonly [number, number]): Float32Array {
|
|
243
|
+
const h = biquad("hp", lo);
|
|
244
|
+
const l = biquad("lp", Math.min(hi, SR * 0.45));
|
|
245
|
+
return filter(filter(filter(filter(x, h), h), l), l);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// [a, b) 창 평균 에너지 — 창이 소리 끝·앞을 넘으면 넘은 자리는 0 으로 친다(잘린 길이로 나누면 짧은 소리가 5dB 넘게 부풀었다)
|
|
249
|
+
const energy = (x: Float32Array, a: number, b: number): number => {
|
|
250
|
+
let e = 0;
|
|
251
|
+
for (let i = Math.max(0, a); i < Math.min(x.length, b); i++) e += x[i] * x[i];
|
|
252
|
+
return b > a ? e / (b - a) : 0;
|
|
253
|
+
};
|
|
254
|
+
const db = (e: number): number => 10 * Math.log10(e + 1e-15);
|
|
255
|
+
|
|
256
|
+
// 100ms 창 RMS 최댓값(dBFS) — tools/sfx/build.ts 의 level 과 같은 잣대
|
|
257
|
+
function peak100(y: Float32Array): number {
|
|
258
|
+
const win = Math.round(SR * 0.1);
|
|
259
|
+
let best = 0;
|
|
260
|
+
for (let i = 0; i < Math.max(1, y.length - win + 1); i += Math.round(SR * 0.01)) best = Math.max(best, energy(y, i, i + win));
|
|
261
|
+
return db(best);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// sfx/ 의 16비트 모노 wav — tools/sfx/build.ts 가 쓴 꼴만
|
|
265
|
+
const raw = new Map<string, Float32Array>();
|
|
266
|
+
function samples(path: string): Float32Array {
|
|
267
|
+
const hit = raw.get(path);
|
|
268
|
+
if (hit) return hit;
|
|
269
|
+
const b = readFileSync(path);
|
|
270
|
+
let p = 12;
|
|
271
|
+
let data: Buffer | null = null;
|
|
272
|
+
let bits = 16;
|
|
273
|
+
let channels = 1;
|
|
274
|
+
while (p + 8 <= b.length) {
|
|
275
|
+
const id = b.toString("ascii", p, p + 4);
|
|
276
|
+
const size = b.readUInt32LE(p + 4);
|
|
277
|
+
if (id === "fmt ") {
|
|
278
|
+
channels = b.readUInt16LE(p + 10);
|
|
279
|
+
bits = b.readUInt16LE(p + 22);
|
|
280
|
+
} else if (id === "data") data = b.subarray(p + 8, p + 8 + size);
|
|
281
|
+
p += 8 + size + (size & 1);
|
|
282
|
+
}
|
|
283
|
+
if (!data || bits !== 16 || channels !== 1) throw new Error(`효과음 파일 꼴이 아니다(16비트 모노 wav): ${path}`);
|
|
284
|
+
const y = new Float32Array(data.length / 2);
|
|
285
|
+
for (let i = 0; i < y.length; i++) y[i] = data.readInt16LE(i * 2) / 32768;
|
|
286
|
+
raw.set(path, y);
|
|
287
|
+
return y;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface Voiced {
|
|
291
|
+
y: Float32Array;
|
|
292
|
+
/** 파일 시작 → 꼭대기(샘플) */
|
|
293
|
+
lead: number;
|
|
294
|
+
level: number;
|
|
295
|
+
/** 소리가 차지하는 대역들과 그 대역으로 거른 소리 */
|
|
296
|
+
bands: { band: readonly [number, number]; y: Float32Array }[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 음높이를 옮기고(선형 보간) 저역을 걷은 소리 — 크기·대역은 걷은 뒤에 잰다
|
|
300
|
+
const voiced = new Map<string, Voiced>();
|
|
301
|
+
function voice(f: BankFile, rate: number, cut: number): Voiced {
|
|
302
|
+
const key = `${f.path}|${rate}|${cut}`;
|
|
303
|
+
const hit = voiced.get(key);
|
|
304
|
+
if (hit) return hit;
|
|
305
|
+
const src = samples(f.path);
|
|
306
|
+
const len = Math.floor((src.length - 1) / rate);
|
|
307
|
+
const moved = new Float32Array(len);
|
|
308
|
+
for (let i = 0; i < len; i++) {
|
|
309
|
+
const x = i * rate;
|
|
310
|
+
const k = Math.floor(x);
|
|
311
|
+
moved[i] = src[k] + (src[k + 1] - src[k]) * (x - k);
|
|
312
|
+
}
|
|
313
|
+
const hp = biquad("hp", cut);
|
|
314
|
+
const y = filter(filter(moved, hp), hp);
|
|
315
|
+
const split = BANDS.map((b) => ({ band: b, y: band(y, b) }));
|
|
316
|
+
const loud = split.map((x) => db(energy(x.y, 0, x.y.length)));
|
|
317
|
+
const top = Math.max(...loud);
|
|
318
|
+
const v = { y, lead: Math.round((f.lead / rate) * SR), level: peak100(y), bands: split.filter((_, i) => loud[i] >= top - 10) };
|
|
319
|
+
voiced.set(key, v);
|
|
320
|
+
return v;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface SfxPlaced extends SfxHit {
|
|
324
|
+
/** 둘레 곡 크기(앞뒤 0.2초 RMS, dBFS) */
|
|
325
|
+
local: number;
|
|
326
|
+
/** 소리 크기(100ms 창, dBFS) − 둘레 곡 */
|
|
327
|
+
over: number;
|
|
328
|
+
/** 가려짐을 잰 대역(Hz) */
|
|
329
|
+
band: readonly [number, number];
|
|
330
|
+
/** 그 대역에서 소리 순간(꼭대기 앞 10ms·뒤 90ms) 효과음 ÷ 눌린 곡(dB) */
|
|
331
|
+
smr: number;
|
|
332
|
+
/** 가려져서 더 키운 dB */
|
|
333
|
+
boost: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 곡(·목소리)을 섞은 판 bed(스테레오, 영상 from 초가 0)에 효과음을 깐다 — 소리마다
|
|
338
|
+
* ① 크기: 둘레 곡 크기 + s.db + 역할 크기(목소리가 있으면 voiceDb 더) ② 그 순간 곡을 s.duck dB 짧게(소리 시작 10ms 앞부터 꼭대기 100ms 뒤까지) 눌러 자리를 내고
|
|
339
|
+
* ③ 소리 대역에서 효과음이 눌린 곡보다 SMR_TARGET 위가 안 되면 BOOST 까지 더 키운다. bed 를 바꿔 쓰고, 깐 소리와 잰 값을 돌려준다
|
|
340
|
+
*/
|
|
341
|
+
export function laySfx(bed: [Float32Array, Float32Array], hits: readonly SfxHit[], bank: Pick<SfxBank, "file">, s: SfxSettings, o: { from: number; voice: boolean }): { stem: Float32Array; placed: SfxPlaced[] } {
|
|
342
|
+
const [L, R] = bed;
|
|
343
|
+
const n = L.length;
|
|
344
|
+
const mono = new Float32Array(n);
|
|
345
|
+
for (let i = 0; i < n; i++) mono[i] = (L[i] + R[i]) / 2;
|
|
346
|
+
// 곡이 비었거나 아주 조용한 곳은 판 전체(무음 뺀 400ms 창 평균)에서 12dB 아래를 바닥으로
|
|
347
|
+
const blocks: number[] = [];
|
|
348
|
+
for (let i = 0; i + 0.4 * SR <= n; i += 0.1 * SR) {
|
|
349
|
+
const e = energy(mono, i, i + 0.4 * SR);
|
|
350
|
+
if (e > 1e-7) blocks.push(e);
|
|
351
|
+
}
|
|
352
|
+
const floor = blocks.length ? db(blocks.reduce((x, y) => x + y, 0) / blocks.length) - 12 : -32;
|
|
353
|
+
const at = (t: number) => Math.round((t - o.from) * SR);
|
|
354
|
+
const items = hits.flatMap((h) => {
|
|
355
|
+
const f = bank.file(h.set, h.role);
|
|
356
|
+
if (!f) return [];
|
|
357
|
+
const v = voice(f, h.rate, ROLE[h.role].cut);
|
|
358
|
+
const peak = at(h.t);
|
|
359
|
+
return [{ h, v, peak, start: peak - v.lead, local: Math.max(floor, db(energy(mono, peak - 0.2 * SR, peak + 0.2 * SR))) }];
|
|
360
|
+
});
|
|
361
|
+
// 누르기 — 소리마다 작은 골짜기, 겹치면 깊은 쪽
|
|
362
|
+
const env = new Float32Array(n).fill(1);
|
|
363
|
+
const dip = 10 ** (-s.duck / 20);
|
|
364
|
+
const rampIn = Math.round(0.01 * SR);
|
|
365
|
+
const rampOut = Math.round(0.04 * SR);
|
|
366
|
+
for (const it of items) {
|
|
367
|
+
const a = it.start - rampIn;
|
|
368
|
+
const b = it.peak + Math.round(0.1 * SR);
|
|
369
|
+
for (let i = Math.max(0, a - rampIn); i < Math.min(n, b + rampOut); i++) {
|
|
370
|
+
const k = i < a ? (i - (a - rampIn)) / rampIn : i > b ? 1 - (i - b) / rampOut : 1;
|
|
371
|
+
env[i] = Math.min(env[i], 1 - (1 - dip) * Math.max(0, Math.min(1, k)));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const ducked = new Float32Array(n);
|
|
375
|
+
for (let i = 0; i < n; i++) ducked[i] = mono[i] * env[i];
|
|
376
|
+
const stem = new Float32Array(n);
|
|
377
|
+
const placed: SfxPlaced[] = [];
|
|
378
|
+
const pre = Math.round(0.2 * SR);
|
|
379
|
+
for (const it of items) {
|
|
380
|
+
const { h, v } = it;
|
|
381
|
+
const target = it.local + s.db + h.db + (o.voice ? s.voiceDb : 0);
|
|
382
|
+
let gain = target - v.level;
|
|
383
|
+
// 가려짐 — 소리 꼭대기 앞 10ms·뒤 90ms 를 그 소리 대역마다. 곡 쪽은 거르개가 자리 잡게 앞 0.2초부터 걸러 뒤만 잰다
|
|
384
|
+
const a = it.peak - Math.round(0.01 * SR);
|
|
385
|
+
const b = it.peak + Math.round(0.09 * SR);
|
|
386
|
+
const ref = ducked.subarray(Math.max(0, a - pre), Math.min(n, b));
|
|
387
|
+
const per = v.bands.map((x) => ({
|
|
388
|
+
band: x.band,
|
|
389
|
+
smr: db(energy(x.y, v.lead - Math.round(0.01 * SR), v.lead + Math.round(0.09 * SR)) * 10 ** (gain / 10)) - db(energy(band(ref, x.band), a - Math.max(0, a - pre), ref.length)),
|
|
390
|
+
}));
|
|
391
|
+
const best = per.reduce((p, q) => (q.smr > p.smr ? q : p));
|
|
392
|
+
const smr0 = best.smr;
|
|
393
|
+
const boost = Math.min(BOOST, Math.max(0, SMR_TARGET - smr0));
|
|
394
|
+
gain += boost;
|
|
395
|
+
const k = 10 ** (gain / 20);
|
|
396
|
+
for (let i = Math.max(0, -it.start); i < v.y.length && it.start + i < n; i++) stem[it.start + i] += k * v.y[i];
|
|
397
|
+
const r = (x: number) => Math.round(x * 10) / 10;
|
|
398
|
+
placed.push({ ...h, local: r(it.local), over: r(v.level + gain - it.local), band: best.band, smr: r(smr0 + boost), boost: r(boost) });
|
|
399
|
+
}
|
|
400
|
+
for (let i = 0; i < n; i++) {
|
|
401
|
+
L[i] = L[i] * env[i] + stem[i];
|
|
402
|
+
R[i] = R[i] * env[i] + stem[i];
|
|
403
|
+
}
|
|
404
|
+
return { stem, placed };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 32비트 실수 wav — 채널을 받은 차례로 엇갈려 쓴다 */
|
|
408
|
+
export function writeFloatWav(path: string, channels: Float32Array[]): string {
|
|
409
|
+
const c = channels.length;
|
|
410
|
+
const n = channels[0].length;
|
|
411
|
+
const body = new Float32Array(n * c);
|
|
412
|
+
for (let i = 0; i < n; i++) for (let k = 0; k < c; k++) body[i * c + k] = channels[k][i];
|
|
413
|
+
const data = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
414
|
+
const head = Buffer.alloc(44);
|
|
415
|
+
head.write("RIFF", 0);
|
|
416
|
+
head.writeUInt32LE(36 + data.length, 4);
|
|
417
|
+
head.write("WAVEfmt ", 8);
|
|
418
|
+
head.writeUInt32LE(16, 16);
|
|
419
|
+
head.writeUInt16LE(3, 20);
|
|
420
|
+
head.writeUInt16LE(c, 22);
|
|
421
|
+
head.writeUInt32LE(SR, 24);
|
|
422
|
+
head.writeUInt32LE(SR * 4 * c, 28);
|
|
423
|
+
head.writeUInt16LE(4 * c, 32);
|
|
424
|
+
head.writeUInt16LE(32, 34);
|
|
425
|
+
head.write("data", 36);
|
|
426
|
+
head.writeUInt32LE(data.length, 40);
|
|
427
|
+
writeFileSync(path, Buffer.concat([head, data]));
|
|
428
|
+
return path;
|
|
429
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 새 판 알림 — Codex CLI 처럼 확인은 떨어진 자식이 뒤에서 하고 결과를 캐시에 둔다. kf 는 네트워크를 기다리지 않고 지난 확인 결과로 묻거나 알린다.
|
|
2
|
+
// 여기는 모든 kf 명령 앞에서 불리니 무거운 것을 import 하지 않는다.
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const PACKAGE = "kasaframe";
|
|
11
|
+
const root = fileURLToPath(new URL("../../../", import.meta.url));
|
|
12
|
+
const versionAt = (dir: string): string => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
|
|
13
|
+
export const current = versionAt(root);
|
|
14
|
+
// 몇 시간에 한 번 — 하루에 몇 번 켜도 레지스트리는 한두 번만 본다
|
|
15
|
+
export const CHECK_EVERY_MS = 6 * 3600_000;
|
|
16
|
+
|
|
17
|
+
export interface UpdateCache {
|
|
18
|
+
latest?: string;
|
|
19
|
+
checkedAt?: string;
|
|
20
|
+
/** 「이 판은 다시 묻지 않기」로 넘긴 판 — 더 새 판이 나오면 다시 묻는다 */
|
|
21
|
+
dismissed?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const cacheFile = (): string => join(process.env.KF_CACHE_DIR ?? join(homedir(), ".cache", "kasaframe"), "update.json");
|
|
25
|
+
|
|
26
|
+
export function readCache(): UpdateCache {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(cacheFile(), "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// kf 여럿이 한꺼번에 써도 반쯤 쓴 파일을 읽지 않게 다른 이름에 쓰고 옮긴다
|
|
35
|
+
export function writeCache(c: UpdateCache): void {
|
|
36
|
+
const file = cacheFile();
|
|
37
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
38
|
+
const tmp = `${file}.${process.pid}`;
|
|
39
|
+
writeFileSync(tmp, `${JSON.stringify(c)}\n`);
|
|
40
|
+
renameSync(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
44
|
+
|
|
45
|
+
/** latest 가 now 보다 새 정식 판인가 — 미리보기 판(prerelease)은 권하지 않는다 */
|
|
46
|
+
export function isNewer(latest: string, now: string): boolean {
|
|
47
|
+
const l = SEMVER.exec(latest.trim());
|
|
48
|
+
const c = SEMVER.exec(now.trim());
|
|
49
|
+
if (!l || !c || l[4]) return false;
|
|
50
|
+
for (let i = 1; i <= 3; i++) {
|
|
51
|
+
const d = Number(l[i]) - Number(c[i]);
|
|
52
|
+
if (d) return d > 0;
|
|
53
|
+
}
|
|
54
|
+
return !!c[4];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Manager = "npm" | "pnpm" | "bun" | "yarn";
|
|
58
|
+
export interface Install {
|
|
59
|
+
manager: Manager;
|
|
60
|
+
/** npm 전역 자리 — 기본 자리가 아니면 올릴 때도 거기에 깔아야 이 kf 가 바뀐다 */
|
|
61
|
+
prefix?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 패키지가 놓인 자리로 어느 관리자의 전역 설치인가. 레포 체크아웃·npx 가 잠깐 받은 것·프로젝트 의존성은 null — 판은 그쪽이 정한다 */
|
|
65
|
+
export function installOf(dir: string, win = process.platform === "win32"): Install | null {
|
|
66
|
+
const p = dir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
67
|
+
if (!p.includes("/node_modules/")) return null;
|
|
68
|
+
if (p.includes("/_npx/")) return null;
|
|
69
|
+
if (p.includes("/.bun/install/global/")) return { manager: "bun" };
|
|
70
|
+
if (/\/pnpm\/global\//i.test(p)) return { manager: "pnpm" };
|
|
71
|
+
if (/\/yarn\/(data\/)?global\/node_modules\//i.test(p)) return { manager: "yarn" };
|
|
72
|
+
const npm = (win ? /^(.+)\/node_modules\/kasaframe$/ : /^(.+)\/lib\/node_modules\/kasaframe$/).exec(p);
|
|
73
|
+
return npm ? { manager: "npm", prefix: win ? npm[1].replace(/\//g, "\\") : npm[1] } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function updateCommand(inst: Install, version: string, npmDefaultPrefix?: string): string[] {
|
|
77
|
+
const spec = `${PACKAGE}@${version}`;
|
|
78
|
+
if (inst.manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
|
79
|
+
if (inst.manager === "bun") return ["bun", "add", "-g", spec];
|
|
80
|
+
if (inst.manager === "yarn") return ["yarn", "global", "add", spec];
|
|
81
|
+
const custom = inst.prefix && npmDefaultPrefix !== undefined && !samePath(inst.prefix, npmDefaultPrefix);
|
|
82
|
+
return ["npm", "i", "-g", spec, ...(custom ? ["--prefix", inst.prefix!] : [])];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function samePath(a: string, b: string): boolean {
|
|
86
|
+
const real = (p: string) => {
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(p);
|
|
89
|
+
} catch {
|
|
90
|
+
return p;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
return real(a) === real(b);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const shell = process.platform === "win32";
|
|
97
|
+
|
|
98
|
+
function npmPrefix(): string | undefined {
|
|
99
|
+
const r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", shell, timeout: 10_000 });
|
|
100
|
+
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 이 kf 를 올리는 명령 — npm 이면 기본 전역 자리와 비교해야 해서 npm 을 한 번 부른다(묻거나 올릴 때만) */
|
|
104
|
+
export function commandFor(version: string): string[] | null {
|
|
105
|
+
const inst = installOf(root);
|
|
106
|
+
return inst && updateCommand(inst, version, inst.manager === "npm" && inst.prefix ? npmPrefix() : undefined);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 환경 변수 → npm 설정(사용자·전역·프로젝트 .npmrc) — 올리는 명령도 같은 npm 설정을 따른다
|
|
110
|
+
export function registry(env: NodeJS.ProcessEnv = process.env): string {
|
|
111
|
+
const set = env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY;
|
|
112
|
+
if (set) return set;
|
|
113
|
+
const r = spawnSync("npm", ["config", "get", "registry"], { encoding: "utf8", shell, timeout: 10_000 });
|
|
114
|
+
const got = r.status === 0 ? r.stdout.trim() : "";
|
|
115
|
+
return /^https?:\/\//.test(got) ? got : "https://registry.npmjs.org/";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function fetchLatest(timeoutMs: number): Promise<string> {
|
|
119
|
+
const url = `${registry().replace(/\/+$/, "")}/${PACKAGE}`;
|
|
120
|
+
const r = await fetch(url, {
|
|
121
|
+
headers: { accept: "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8" },
|
|
122
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
123
|
+
});
|
|
124
|
+
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
|
|
125
|
+
const latest = ((await r.json()) as { "dist-tags"?: { latest?: unknown } })["dist-tags"]?.latest;
|
|
126
|
+
if (typeof latest !== "string") throw new Error(`${url} 에 dist-tags.latest 가 없다`);
|
|
127
|
+
return latest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 레지스트리를 지금 보고 캐시에 적는다 — kf update·kf doctor 와 뒤에서 도는 자식이 쓴다 */
|
|
131
|
+
export async function refresh(timeoutMs = 8000): Promise<string> {
|
|
132
|
+
const latest = await fetchLatest(timeoutMs);
|
|
133
|
+
writeCache({ ...readCache(), latest, checkedAt: new Date().toISOString() });
|
|
134
|
+
return latest;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const on = (v: string | undefined) => !!v && v !== "0" && v.toLowerCase() !== "false";
|
|
138
|
+
|
|
139
|
+
export interface Decision {
|
|
140
|
+
show: "prompt" | "notice" | "none";
|
|
141
|
+
refresh: boolean;
|
|
142
|
+
latest?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function decide(i: { current: string; cache: UpdateCache; install: Install | null; tty: boolean; env: NodeJS.ProcessEnv; now: number }): Decision {
|
|
146
|
+
if (!i.install || on(i.env.KF_NO_UPDATE_CHECK) || on(i.env.CI)) return { show: "none", refresh: false };
|
|
147
|
+
// 시각이 없거나 미래(시계가 돌아갔다)면 지금 본다
|
|
148
|
+
const age = i.now - Date.parse(i.cache.checkedAt ?? "");
|
|
149
|
+
const refresh = !(age >= 0 && age < CHECK_EVERY_MS);
|
|
150
|
+
const latest = i.cache.latest;
|
|
151
|
+
if (!latest || !isNewer(latest, i.current) || i.cache.dismissed === latest) return { show: "none", refresh };
|
|
152
|
+
return { show: i.tty ? "prompt" : "notice", refresh, latest };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 뜻이 고정된 한 줄 — 에이전트가 이 꼴을 보고 kf update --yes 를 돌린다(skills/kasaframe/SKILL.md)
|
|
156
|
+
export const noticeLine = (latest: string, now = current): string => `[kasaframe] 새 판 ${latest} (지금 ${now}) — kf update`;
|
|
157
|
+
|
|
158
|
+
// 확인이 안 돼도(캐시 자리를 못 쓰거나 오프라인) kf 명령은 그대로 돈다
|
|
159
|
+
function refreshInBackground(): void {
|
|
160
|
+
try {
|
|
161
|
+
// 같이 뜬 kf 들이 저마다 확인을 띄우지 않게 시각부터 적는다 — 실패하면 다음 주기에 다시 본다
|
|
162
|
+
writeCache({ ...readCache(), checkedAt: new Date().toISOString() });
|
|
163
|
+
const code = `const m = await import(${JSON.stringify(import.meta.url)}); await m.refresh(15000).catch(() => {});`;
|
|
164
|
+
spawn(process.execPath, ["--input-type=module", "-e", code], { detached: true, stdio: "ignore", windowsHide: true })
|
|
165
|
+
.on("error", () => {})
|
|
166
|
+
.unref();
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 설치본 입구가 NODE_OPTIONS 에 넣은 형 지우기 훅 — 새 판으로 다시 띄울 땐 빼야 새 입구가 제 훅을 넣는다(pnpm 은 옛 판 자리를 지운다)
|
|
171
|
+
const hooksArg = `--import=${new URL("../../../bin/hooks.mjs", import.meta.url).href}`;
|
|
172
|
+
|
|
173
|
+
function install(cmd: string[]): { ok: boolean; why: string } {
|
|
174
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit", shell });
|
|
175
|
+
if (r.error) return { ok: false, why: r.error.message };
|
|
176
|
+
return { ok: r.status === 0, why: r.signal ? `신호 ${r.signal}` : `종료 ${r.status}` };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 사람이 친 kf 입구(심볼릭 링크 그대로)가 이제 가리키는 판 */
|
|
180
|
+
function versionBehind(entry: string): string | null {
|
|
181
|
+
try {
|
|
182
|
+
return versionAt(join(dirname(realpathSync(entry)), ".."));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 올리고, 이 kf 입구가 실제로 새 판을 가리키는지까지 본다 */
|
|
189
|
+
export function upgrade(cmd: string[], latest: string, entry = process.argv[1]): boolean {
|
|
190
|
+
process.stderr.write(`\n${cmd.join(" ")}\n`);
|
|
191
|
+
const r = install(cmd);
|
|
192
|
+
if (!r.ok) {
|
|
193
|
+
process.stderr.write(`\n올리지 못했다(${r.why}). 직접 올리려면: ${cmd.join(" ")}\n`);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const now = versionBehind(entry);
|
|
197
|
+
if (now !== latest) {
|
|
198
|
+
process.stderr.write(`\n설치는 끝났는데 이 kf(${entry})는 ${now ?? "?"} 그대로다 — 다른 자리에 깔렸다. 직접: ${cmd.join(" ")}\n`);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ask(latest: string, cmd: string[]): Promise<"update" | "skip" | "dismiss"> {
|
|
205
|
+
// 묻는 동안 표준 출력이 터미널임은 확인했다 — 표준 오류가 파일로 가 있어도 보이게 표준 출력에 묻는다
|
|
206
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
207
|
+
rl.on("SIGINT", () => {
|
|
208
|
+
rl.close();
|
|
209
|
+
process.exit(130);
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
const a = (
|
|
213
|
+
await rl.question(
|
|
214
|
+
`\n새 판이 있다 · kasaframe ${current} → ${latest}\n 1) 지금 업데이트 (${cmd.join(" ")})\n 2) 건너뛰기\n 3) 이 판은 다시 묻지 않기\n번호(엔터 = 1): `,
|
|
215
|
+
)
|
|
216
|
+
).trim();
|
|
217
|
+
return a === "" || a === "1" ? "update" : a === "3" ? "dismiss" : "skip";
|
|
218
|
+
} catch {
|
|
219
|
+
return "skip";
|
|
220
|
+
} finally {
|
|
221
|
+
rl.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 명령 앞 관문. 터미널이면 보기 셋을 묻고, 아니면 표준 오류에 한 줄(표준 출력은 --json 을 읽는 도구가 있어 건드리지 않는다).
|
|
227
|
+
* 「지금 업데이트」가 되면 원래 명령을 새 판으로 다시 띄우고 그 종료 코드를, 아니면 null(지금 판으로 이어 간다)
|
|
228
|
+
*/
|
|
229
|
+
export async function updateGate(argv: string[]): Promise<number | null> {
|
|
230
|
+
const env = process.env;
|
|
231
|
+
const d = decide({ current, cache: readCache(), install: installOf(root), tty: !!(process.stdin.isTTY && process.stdout.isTTY), env, now: Date.now() });
|
|
232
|
+
// kf 가 띄우는 kf(make → render 따위)는 다시 묻거나 찍지 않는다
|
|
233
|
+
env.KF_NO_UPDATE_CHECK = "1";
|
|
234
|
+
if (d.refresh) refreshInBackground();
|
|
235
|
+
if (d.show === "notice") process.stderr.write(`${noticeLine(d.latest!)}\n`);
|
|
236
|
+
if (d.show !== "prompt") return null;
|
|
237
|
+
|
|
238
|
+
const cmd = commandFor(d.latest!)!;
|
|
239
|
+
const pick = await ask(d.latest!, cmd);
|
|
240
|
+
if (pick === "dismiss") writeCache({ ...readCache(), dismissed: d.latest });
|
|
241
|
+
if (pick !== "update") return null;
|
|
242
|
+
if (!upgrade(cmd, d.latest!)) {
|
|
243
|
+
process.stderr.write(`${current} 로 이어 간다\n\n`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
process.stderr.write(`\nkasaframe ${d.latest} 로 올렸다 — 이어서 kf ${argv.join(" ")}\n\n`);
|
|
247
|
+
const nodeOptions = (env.NODE_OPTIONS ?? "").split(" ").filter((t) => t && t !== hooksArg).join(" ");
|
|
248
|
+
const r = spawnSync(process.execPath, [process.argv[1], ...argv], { stdio: "inherit", env: { ...env, NODE_OPTIONS: nodeOptions } });
|
|
249
|
+
return r.status ?? (r.signal === "SIGINT" ? 130 : 1);
|
|
250
|
+
}
|