@gtanczyk/diorama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Optional, precomputed cues only apply to the exact final audio analysed by the prototype.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
5
|
+
import { MOUTHS, type LipSync, type MouthCue } from '../play/lipsync.ts';
|
|
6
|
+
|
|
7
|
+
export function lipSyncData(files: WorldFiles): Record<string, LipSync> {
|
|
8
|
+
const file = files.path('sound', 'lipsync.json');
|
|
9
|
+
if (!fs.existsSync(file)) return {};
|
|
10
|
+
try {
|
|
11
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as { version: number; byFile: Record<string, LipSync & { sha256: string }> };
|
|
12
|
+
if (manifest.version !== 1) return {};
|
|
13
|
+
return Object.fromEntries(Object.entries(manifest.byFile).filter(([audio, data]) => {
|
|
14
|
+
if (!data || typeof data !== 'object') return false;
|
|
15
|
+
if (data.step !== undefined && (!Number.isFinite(data.step) || data.step <= 0)) return false;
|
|
16
|
+
if (data.envelope !== undefined && (!Array.isArray(data.envelope) || !data.envelope.every(v => Number.isFinite(v) && v >= 0 && v <= 1))) return false;
|
|
17
|
+
if (!audio.startsWith('sound/voice/') || audio.includes('..') || !fs.existsSync(files.path(audio))) return false;
|
|
18
|
+
if (createHash('sha256').update(fs.readFileSync(files.path(audio))).digest('hex') !== data.sha256) return false;
|
|
19
|
+
return Array.isArray(data.cues) && data.cues.length > 0 && data.cues.every((cue: MouthCue, i: number) =>
|
|
20
|
+
!!cue && MOUTHS.includes(cue.value) && Number.isFinite(cue.start) && Number.isFinite(cue.end) && cue.start >= 0 && cue.end > cue.start
|
|
21
|
+
&& (!i || cue.start >= data.cues[i - 1].end));
|
|
22
|
+
}).map(([audio, { cues, envelope, step }]) => [audio, { cues, envelope, step }]));
|
|
23
|
+
} catch { return {}; } // An old or unfinished prototype never prevents a world from playing.
|
|
24
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Turns a sound plan into files: generates each take, checks it, makes loops seamless, cuts footsteps,
|
|
2
|
+
// levels everything, and writes sound/<id>.mp3 plus sound/sounds.json for the runtime.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { emit } from '../registry.ts';
|
|
7
|
+
import { inputHash, type Memo } from '../meta/memo.ts';
|
|
8
|
+
import { sharedDir, soundQuality } from './cost.ts';
|
|
9
|
+
import { audioProblem, decode, encodeMp3, hiss, loopProblem, tonalShare, normalise, seamless, seconds, steps, trim, type Pcm } from './pcm.ts';
|
|
10
|
+
import { soundIds, type SoundPlan } from './plan.ts';
|
|
11
|
+
import { elevenKey, elevenMusic, forgetEffect, musicgen, MUSICGEN_MODEL, soundEffect, type EffectAsk } from './sources.ts';
|
|
12
|
+
import type { SoundFixes } from './fixes.ts';
|
|
13
|
+
|
|
14
|
+
export const Kind = z.enum(['music', 'bed', 'loop', 'step', 'shot']);
|
|
15
|
+
export type Kind = z.infer<typeof Kind>;
|
|
16
|
+
|
|
17
|
+
export const SoundEntry = z.object({
|
|
18
|
+
file: z.string(),
|
|
19
|
+
kind: Kind,
|
|
20
|
+
level: z.number().default(2),
|
|
21
|
+
seconds: z.number(),
|
|
22
|
+
/** Footsteps: [start, duration] of each single step inside the file. */
|
|
23
|
+
slices: z.array(z.tuple([z.number(), z.number()])).optional(),
|
|
24
|
+
source: z.string(),
|
|
25
|
+
prompt: z.string(),
|
|
26
|
+
flags: z.array(z.string()).default([]),
|
|
27
|
+
/** Made in draft or final quality; sounds made before qualities existed were full length. */
|
|
28
|
+
quality: z.enum(['draft', 'final']).default('final'),
|
|
29
|
+
/** How ambience was mixed: below MIX it is made again (from the memo, at no cost) with the current levels and hiss check. */
|
|
30
|
+
mix: z.number().optional(),
|
|
31
|
+
validation: z.number().optional(),
|
|
32
|
+
});
|
|
33
|
+
export const SoundManifest = z.object({ sounds: z.record(z.string(), SoundEntry), failures: z.record(z.string(), z.string()).default({}) });
|
|
34
|
+
export type SoundEntry = z.infer<typeof SoundEntry>;
|
|
35
|
+
export type SoundManifest = z.infer<typeof SoundManifest>;
|
|
36
|
+
|
|
37
|
+
/** Loudness each kind is levelled to before the mixer's own gains, dBFS RMS. */
|
|
38
|
+
// Ambience sits well under the music: at -24 a bed of wind and leaves was a constant hiss over the whole game.
|
|
39
|
+
const TARGET_DB: Record<Kind, number> = { music: -20, bed: -31, loop: -28, step: -16, shot: -17 };
|
|
40
|
+
/** Ambience made with the levels and the hiss check above; older beds and loops are made again. */
|
|
41
|
+
export const MIX = 3;
|
|
42
|
+
export const AUDIO_VALIDATION = 1;
|
|
43
|
+
/** Above this a bed or loop is mostly hiss (energy of the sample-to-sample change over the energy: white noise is 2, music 0.06). */
|
|
44
|
+
export const HISS = 0.4;
|
|
45
|
+
const NO_HISS = '. Not wind, not rustling, no hiss or air noise: soft distant life and warm low tones';
|
|
46
|
+
/** Mostly hiss: high-frequency energy that is noise. Crickets, birdsong and other tones are not hiss, however bright. */
|
|
47
|
+
export const hissy = (pcm: Pcm) => hiss(pcm) > HISS && tonalShare(pcm) < 0.3;
|
|
48
|
+
/** Sounds that are hiss by nature (a waterfall's roar, rain, surf): three takes of one were all "mostly hiss" and it stayed silent. */
|
|
49
|
+
export const HISS_BY_NATURE = /\b(waterfalls?|cascades?|rain|downpour|drizzle|sea|ocean|surf|waves?|breakers|rapids|river|torrent|spray|shower|sizzl\w*|steam)\b/i;
|
|
50
|
+
/** What is wrong with a bed or loop being hiss: nothing for a loop on a thing that hisses by nature. A bed under the whole game still may not. */
|
|
51
|
+
export const hissProblem = (pcm: Pcm, kind: Kind, prompt: string) => (!(kind === 'loop' && HISS_BY_NATURE.test(prompt)) && hissy(pcm) ? 'mostly hiss' : '');
|
|
52
|
+
/**
|
|
53
|
+
* What the next take asks for, from what was wrong with the last one: only a hissy take is asked for no hiss. Asking
|
|
54
|
+
* a quiet one to be "gentle and quiet" (or even "clearly audible": the level does not follow the words) does not help.
|
|
55
|
+
*/
|
|
56
|
+
export function retakePrompt(prompt: string, take: number, problem: string): string {
|
|
57
|
+
if (take === 1) return prompt;
|
|
58
|
+
return prompt + STEADY + (/hiss/.test(problem) ? NO_HISS : '');
|
|
59
|
+
}
|
|
60
|
+
const LENGTH: Record<Exclude<Kind, 'shot'>, number> = { music: 30, bed: 20, loop: 10, step: 6 };
|
|
61
|
+
/** Draft lengths: ElevenLabs bills effects by the second, and a seamless loop repeats fine at half the length. */
|
|
62
|
+
const DRAFT: Record<Exclude<Kind, 'shot'>, number> = { music: 30, bed: 10, loop: 6, step: 4 };
|
|
63
|
+
const length = (kind: Exclude<Kind, 'shot'>) => (soundQuality() === 'final' ? LENGTH : DRAFT)[kind];
|
|
64
|
+
|
|
65
|
+
/** Sounds that do not belong to one world: footsteps per surface and the UI one-shots. Drafts reuse them across worlds. */
|
|
66
|
+
const GENERIC = /^(step-.+|shot-(take|click|talk|ending))$/;
|
|
67
|
+
const libraryFile = (id: string, prompt: string, ext: string) => sharedDir('library', `${id}-${inputHash(prompt)}.${ext}`);
|
|
68
|
+
|
|
69
|
+
function fromLibrary(job: Job): Made | undefined {
|
|
70
|
+
const { id, prompt, kind, level } = job;
|
|
71
|
+
if (!GENERIC.test(id)) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const entry = JSON.parse(fs.readFileSync(libraryFile(id, prompt, 'json'), 'utf8')) as Made['entry'];
|
|
74
|
+
if (entry.prompt !== prompt || entry.kind !== kind || entry.validation !== AUDIO_VALIDATION
|
|
75
|
+
|| (soundQuality() === 'final' && entry.quality !== 'final')) return undefined;
|
|
76
|
+
return { mp3: fs.readFileSync(libraryFile(id, prompt, 'mp3')), entry: { ...entry, level, source: `library (${entry.source})` } };
|
|
77
|
+
} catch { return undefined; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A world's own generic sound joins the library too, so the next world in draft reuses it. */
|
|
81
|
+
export function seedLibrary(id: string, entry: SoundEntry, mp3: Buffer) {
|
|
82
|
+
if (entry.validation === AUDIO_VALIDATION) toLibrary(id, { mp3, entry });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toLibrary(id: string, made: Made) {
|
|
86
|
+
if (!GENERIC.test(id) || made.entry.flags.length || made.entry.source.startsWith('library')) return;
|
|
87
|
+
fs.mkdirSync(path.dirname(libraryFile(id, made.entry.prompt, 'mp3')), { recursive: true });
|
|
88
|
+
fs.writeFileSync(libraryFile(id, made.entry.prompt, 'mp3'), made.mp3);
|
|
89
|
+
fs.writeFileSync(libraryFile(id, made.entry.prompt, 'json'), JSON.stringify(made.entry));
|
|
90
|
+
}
|
|
91
|
+
const STEADY = ', continuous and steady throughout, no single standout events, no silence, seamless loop';
|
|
92
|
+
|
|
93
|
+
/** `retake`: rounds of takes asked for again (sound_redo); each round asks for new takes instead of replaying cached ones. */
|
|
94
|
+
export type Job = { id: string; kind: Kind; prompt: string; seconds: number; level: number; retake?: number };
|
|
95
|
+
|
|
96
|
+
/** The sounds a plan asks for, with the person's hints (sound/fixes.json) added to their prompts. */
|
|
97
|
+
export function jobsOf(plan: SoundPlan, fixes: SoundFixes = {}): Job[] {
|
|
98
|
+
return planJobs(plan).map((j) => {
|
|
99
|
+
const fix = fixes[j.id];
|
|
100
|
+
return fix ? { ...j, ...(fix.hint && { prompt: `${j.prompt}. ${fix.hint}` }), ...(fix.take && { retake: fix.take }) } : j;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function planJobs(plan: SoundPlan): Job[] {
|
|
105
|
+
const jobs: Job[] = [{ id: soundIds.music(), kind: 'music', prompt: plan.music.prompt, seconds: length('music'), level: 2 }];
|
|
106
|
+
for (const [place, p] of Object.entries(plan.places)) {
|
|
107
|
+
jobs.push({ id: soundIds.bed(place), kind: 'bed', prompt: p.bed, seconds: length('bed'), level: 2 });
|
|
108
|
+
for (const loop of p.loops) jobs.push({ id: soundIds.loop(place, loop.thing), kind: 'loop', prompt: loop.prompt, seconds: length('loop'), level: loop.level });
|
|
109
|
+
}
|
|
110
|
+
for (const [surface, prompt] of Object.entries(plan.steps)) jobs.push({ id: soundIds.step(surface), kind: 'step', prompt, seconds: length('step'), level: 2 });
|
|
111
|
+
for (const shot of plan.shots) jobs.push({ id: soundIds.shot(shot.on), kind: 'shot', prompt: shot.prompt, seconds: shot.seconds, level: shot.level });
|
|
112
|
+
return jobs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Made = { mp3: Buffer; entry: Omit<SoundEntry, 'file'> };
|
|
116
|
+
|
|
117
|
+
async function makeOne(memo: Memo, job: Job, music: { python?: string }): Promise<Made> {
|
|
118
|
+
const flags: string[] = [];
|
|
119
|
+
const entry = (pcm: Pcm, source: string, extra: Partial<SoundEntry> = {}): Made => ({
|
|
120
|
+
mp3: encodeMp3(pcm, job.kind === 'step' || job.kind === 'shot' ? 160 : 128),
|
|
121
|
+
entry: { kind: job.kind, level: job.level, seconds: Math.round(seconds(pcm) * 1000) / 1000, source, prompt: job.prompt, flags, quality: soundQuality(), validation: AUDIO_VALIDATION, ...extra },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (job.kind === 'music') {
|
|
125
|
+
// Meta's MusicGen when Python is set up for it; otherwise ElevenLabs music.
|
|
126
|
+
const raw = music.python
|
|
127
|
+
? await musicgen(memo, job.id, job.prompt, job.seconds, music.python)
|
|
128
|
+
: await elevenMusic(memo, job.id, job.prompt, job.seconds);
|
|
129
|
+
const decoded = decode(raw);
|
|
130
|
+
const problem = audioProblem(decoded);
|
|
131
|
+
if (problem) throw new Error(`music rejected: ${problem}`);
|
|
132
|
+
const pcm = normalise(seamless(decoded, 3), TARGET_DB.music);
|
|
133
|
+
return entry(pcm, music.python ? MUSICGEN_MODEL : 'elevenlabs-music');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (job.kind === 'bed' || job.kind === 'loop') {
|
|
137
|
+
let problem = '';
|
|
138
|
+
const round = 3 * (job.retake ?? 0);
|
|
139
|
+
for (let take = 1; take <= 3; take++) {
|
|
140
|
+
const prompt = retakePrompt(job.prompt, take, problem);
|
|
141
|
+
const ask: EffectAsk = { label: `${job.id}-${round + take}`, prompt, seconds: job.seconds, loop: true, take: round + take };
|
|
142
|
+
const pcm = decode(await soundEffect(memo, ask));
|
|
143
|
+
problem = audioProblem(pcm) ?? loopProblem(pcm, { beats: job.kind === 'loop' }) ?? hissProblem(pcm, job.kind, job.prompt);
|
|
144
|
+
if (problem) forgetEffect(memo, ask);
|
|
145
|
+
if (!problem) return entry(normalise(seamless(pcm, job.kind === 'bed' ? 1.5 : 0.8), TARGET_DB[job.kind]), 'elevenlabs', { mix: MIX });
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`loop rejected after 3 takes: ${problem}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (job.kind === 'step') {
|
|
151
|
+
const round = 2 * (job.retake ?? 0);
|
|
152
|
+
let pcm = decode(await soundEffect(memo, { label: job.id, prompt: job.prompt, seconds: job.seconds, loop: false, ...(round ? { take: round + 1 } : {}) }));
|
|
153
|
+
let slices = audioProblem(pcm) ? [] : steps(pcm);
|
|
154
|
+
if (slices.length < 3) {
|
|
155
|
+
const retry = decode(await soundEffect(memo, { label: `${job.id}-${round + 2}`, prompt: `${job.prompt}, clearly separated single footsteps with short pauses`, seconds: job.seconds, loop: false, take: round + 2 }));
|
|
156
|
+
const again = audioProblem(retry) ? [] : steps(retry);
|
|
157
|
+
flags.push(`first take had ${slices.length} separate steps${again.length > slices.length ? ', second take used' : ''}`);
|
|
158
|
+
if (again.length > slices.length) [pcm, slices] = [retry, again];
|
|
159
|
+
}
|
|
160
|
+
if (slices.length < 2) throw new Error(`footsteps rejected: only ${slices.length} separate steps`);
|
|
161
|
+
const problem = audioProblem(pcm);
|
|
162
|
+
if (problem) throw new Error(`footsteps rejected: ${problem}`);
|
|
163
|
+
return entry(normalise(pcm, TARGET_DB.step), 'elevenlabs', { slices });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let problem = '';
|
|
167
|
+
for (let take = 1; take <= 2; take++) {
|
|
168
|
+
const t = 2 * (job.retake ?? 0) + take;
|
|
169
|
+
const ask: EffectAsk = { label: `${job.id}-${t}`, prompt: job.prompt, seconds: job.seconds, loop: false, take: t };
|
|
170
|
+
const pcm = trim(decode(await soundEffect(memo, ask)));
|
|
171
|
+
problem = audioProblem(pcm) ?? '';
|
|
172
|
+
if (problem) forgetEffect(memo, ask);
|
|
173
|
+
if (!problem) return entry(normalise(pcm, TARGET_DB.shot), 'elevenlabs');
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`effect rejected: ${problem}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Runs `jobs` a few at a time. */
|
|
179
|
+
async function pool<T, R>(items: T[], size: number, run: (item: T) => Promise<R>): Promise<R[]> {
|
|
180
|
+
const out: R[] = new Array(items.length);
|
|
181
|
+
let next = 0;
|
|
182
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
|
|
183
|
+
while (next < items.length) {
|
|
184
|
+
const i = next++;
|
|
185
|
+
out[i] = await run(items[i]);
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Makes the plan's sounds. A sound the world already has (same id, kind and prompt, file present, at least this
|
|
193
|
+
* quality) is kept as it is: a rebuild pays only for what the plan changed.
|
|
194
|
+
*/
|
|
195
|
+
export async function makeSounds(memo: Memo, plan: SoundPlan, write: (id: string, mp3: Buffer) => string, opts: { python?: string; only?: Kind[]; ids?: string[]; fixes?: SoundFixes; log?: (line: string) => void; have?: (entry: SoundEntry) => boolean; old?: Record<string, SoundEntry>; failedBefore?: Record<string, string> }): Promise<SoundManifest> {
|
|
196
|
+
if (!elevenKey()) throw new Error('no ElevenLabs key: set ELEVENLABS_API_KEY to generate sounds (the plan is written without it)');
|
|
197
|
+
const sounds: Record<string, SoundEntry> = {};
|
|
198
|
+
const failures: Record<string, string> = {};
|
|
199
|
+
const jobs = jobsOf(plan, opts.fixes).filter((j) => (!opts.only || opts.only.includes(j.kind)) && (!opts.ids || opts.ids.includes(j.id))).filter((j) => {
|
|
200
|
+
const old = opts.old?.[j.id];
|
|
201
|
+
// A sound asked for by id (sound_redo) is made again even when its prompt did not change.
|
|
202
|
+
const keep = !opts.ids && old && old.validation === AUDIO_VALIDATION && old.kind === j.kind && old.prompt === j.prompt && (old.quality === 'final' || soundQuality() === 'draft') && ((j.kind !== 'bed' && j.kind !== 'loop') || (old.mix ?? 0) >= MIX) && opts.have?.(old);
|
|
203
|
+
if (keep) sounds[j.id] = old;
|
|
204
|
+
return !keep;
|
|
205
|
+
});
|
|
206
|
+
if (Object.keys(sounds).length) opts.log?.(` ${Object.keys(sounds).length} sounds kept from the last run (prompt unchanged)`);
|
|
207
|
+
// Rejected last time: say so, since this run asks for new takes (the rejected ones were forgotten) and pays again.
|
|
208
|
+
const again = jobs.filter((j) => opts.failedBefore?.[j.id]);
|
|
209
|
+
if (again.length) opts.log?.(` ${again.length} sounds rejected last time, asked for new takes: ${again.map((j) => `${j.id} (${opts.failedBefore![j.id]})`).join('; ')}`);
|
|
210
|
+
let completed = 0;
|
|
211
|
+
const kept = Object.keys(sounds).length;
|
|
212
|
+
const progress = () => emit('work.progress', { kind: 'sounds', completed, total: jobs.length, failed: Object.keys(failures).length, kept });
|
|
213
|
+
progress();
|
|
214
|
+
await pool(jobs, 3, async (job) => {
|
|
215
|
+
try {
|
|
216
|
+
const made = fromLibrary(job) ?? (await makeOne(memo, job, { python: opts.python }));
|
|
217
|
+
toLibrary(job.id, made);
|
|
218
|
+
sounds[job.id] = { file: write(job.id, made.mp3), ...made.entry };
|
|
219
|
+
opts.log?.(` ${job.id.padEnd(34)} ${made.entry.seconds.toFixed(1).padStart(5)} s${made.entry.slices ? `, ${made.entry.slices.length} steps` : ''}${made.entry.flags.length ? ` flag: ${made.entry.flags.join('; ')}` : ''}`);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
failures[job.id] = error instanceof Error ? error.message : String(error);
|
|
222
|
+
opts.log?.(` ${job.id.padEnd(34)} FAILED, stays silent: ${error instanceof Error ? error.message : error}`);
|
|
223
|
+
} finally {
|
|
224
|
+
completed++;
|
|
225
|
+
progress();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return { sounds, failures };
|
|
229
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# MusicGen (Meta, open weights) on CPU: python musicgen.py <model> "<prompt>" <seconds> <out.wav>
|
|
2
|
+
# Needs torch, transformers and scipy; the sound step runs it through DIORAMA_PYTHON.
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import scipy.io.wavfile
|
|
6
|
+
import torch
|
|
7
|
+
from transformers import AutoProcessor, MusicgenForConditionalGeneration
|
|
8
|
+
|
|
9
|
+
model_id, prompt, seconds, out = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4]
|
|
10
|
+
torch.manual_seed(7)
|
|
11
|
+
processor = AutoProcessor.from_pretrained(model_id)
|
|
12
|
+
model = MusicgenForConditionalGeneration.from_pretrained(model_id)
|
|
13
|
+
inputs = processor(text=[prompt], padding=True, return_tensors="pt")
|
|
14
|
+
# MusicGen makes about 50 tokens per second of audio.
|
|
15
|
+
tokens = int(seconds * model.config.audio_encoder.frame_rate)
|
|
16
|
+
with torch.no_grad():
|
|
17
|
+
audio = model.generate(**inputs, do_sample=True, guidance_scale=3.0, max_new_tokens=tokens)
|
|
18
|
+
rate = model.config.audio_encoder.sampling_rate
|
|
19
|
+
scipy.io.wavfile.write(out, rate=rate, data=audio[0, 0].numpy())
|
|
20
|
+
print(f"musicgen: {audio.shape[-1] / rate:.1f} s at {rate} Hz -> {out}", file=sys.stderr)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Audio as plain samples: ffmpeg decodes and encodes, everything else (loop checks, seams, slicing steps) is code.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export const RATE = 44100;
|
|
5
|
+
|
|
6
|
+
/** Interleaved stereo float samples at RATE. */
|
|
7
|
+
export type Pcm = { left: Float32Array; right: Float32Array };
|
|
8
|
+
|
|
9
|
+
function ffmpeg(args: string[], input: Buffer): Buffer {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...args], { input, maxBuffer: 1 << 30 });
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
14
|
+
if (code === 'ENOENT') throw new Error('the sound step needs ffmpeg on PATH (brew install ffmpeg / apt install ffmpeg)');
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decode(audio: Buffer): Pcm {
|
|
20
|
+
const raw = ffmpeg(['-i', 'pipe:0', '-f', 'f32le', '-ac', '2', '-ar', String(RATE), 'pipe:1'], audio);
|
|
21
|
+
const all = new Float32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4);
|
|
22
|
+
const n = all.length / 2;
|
|
23
|
+
const left = new Float32Array(n);
|
|
24
|
+
const right = new Float32Array(n);
|
|
25
|
+
for (let i = 0; i < n; i++) (left[i] = all[2 * i]), (right[i] = all[2 * i + 1]);
|
|
26
|
+
return { left, right };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function encodeMp3(pcm: Pcm, kbps = 128): Buffer {
|
|
30
|
+
const n = pcm.left.length;
|
|
31
|
+
const all = new Float32Array(n * 2);
|
|
32
|
+
for (let i = 0; i < n; i++) (all[2 * i] = pcm.left[i]), (all[2 * i + 1] = pcm.right[i]);
|
|
33
|
+
return ffmpeg(['-f', 'f32le', '-ac', '2', '-ar', String(RATE), '-i', 'pipe:0', '-c:a', 'libmp3lame', '-b:a', `${kbps}k`, '-f', 'mp3', 'pipe:1'], Buffer.from(all.buffer));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Raises (or lowers) the pitch by `semitones` and keeps the length: a young adult voice read a few semitones up
|
|
38
|
+
* sounds like a child, which no stock voice does.
|
|
39
|
+
*/
|
|
40
|
+
export function pitchShift(pcm: Pcm, semitones: number): Pcm {
|
|
41
|
+
if (!semitones) return pcm;
|
|
42
|
+
const r = 2 ** (semitones / 12);
|
|
43
|
+
const n = pcm.left.length;
|
|
44
|
+
const all = new Float32Array(n * 2);
|
|
45
|
+
for (let i = 0; i < n; i++) (all[2 * i] = pcm.left[i]), (all[2 * i + 1] = pcm.right[i]);
|
|
46
|
+
const out = ffmpeg(['-f', 'f32le', '-ac', '2', '-ar', String(RATE), '-i', 'pipe:0', '-af', `asetrate=${Math.round(RATE * r)},aresample=${RATE},atempo=${(1 / r).toFixed(5)}`, '-f', 'f32le', '-ac', '2', '-ar', String(RATE), 'pipe:1'], Buffer.from(all.buffer));
|
|
47
|
+
const f = new Float32Array(out.buffer, out.byteOffset, out.byteLength / 4);
|
|
48
|
+
const m = f.length / 2;
|
|
49
|
+
const left = new Float32Array(m);
|
|
50
|
+
const right = new Float32Array(m);
|
|
51
|
+
for (let i = 0; i < m; i++) (left[i] = f[2 * i]), (right[i] = f[2 * i + 1]);
|
|
52
|
+
return { left, right };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const seconds = (pcm: Pcm) => pcm.left.length / RATE;
|
|
56
|
+
|
|
57
|
+
/** Objective recording defects, before normalisation can disguise silence or clipping. */
|
|
58
|
+
export function audioProblem(pcm: Pcm): string | undefined {
|
|
59
|
+
if (pcm.left.length !== pcm.right.length || seconds(pcm) < 0.08) return 'empty or too short';
|
|
60
|
+
let energy = 0, clipped = 0;
|
|
61
|
+
for (const channel of [pcm.left, pcm.right]) for (const v of channel) {
|
|
62
|
+
if (!Number.isFinite(v)) return 'non-finite audio samples';
|
|
63
|
+
energy += v * v;
|
|
64
|
+
if (Math.abs(v) >= 0.999) clipped++;
|
|
65
|
+
}
|
|
66
|
+
const count = pcm.left.length * 2;
|
|
67
|
+
if (Math.sqrt(energy / count) < 0.0001) return 'nearly silent';
|
|
68
|
+
if (clipped / count > 0.01) return 'clipped recording';
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The median pitch of a voice in Hz (autocorrelation over voiced 40 ms frames): about 100-130 for a man,
|
|
74
|
+
* 180-230 for a woman, 260 and up for a child. 0 when nothing voiced is found.
|
|
75
|
+
*/
|
|
76
|
+
export function pitchHz(pcm: Pcm): number {
|
|
77
|
+
const step = 3; // 44.1 kHz down to 14.7 kHz is plenty for 70-500 Hz
|
|
78
|
+
const sr = RATE / step;
|
|
79
|
+
const x = new Float32Array(Math.floor(pcm.left.length / step)).map((_, i) => (pcm.left[i * step] + pcm.right[i * step]) / 2);
|
|
80
|
+
const w = Math.round(sr * 0.04);
|
|
81
|
+
const lo = Math.floor(sr / 500);
|
|
82
|
+
const hi = Math.ceil(sr / 70);
|
|
83
|
+
const found: number[] = [];
|
|
84
|
+
for (let at = 0; at + w + hi < x.length; at += w >> 1) {
|
|
85
|
+
let energy = 0;
|
|
86
|
+
for (let i = 0; i < w; i++) energy += x[at + i] ** 2;
|
|
87
|
+
if (Math.sqrt(energy / w) < 0.02) continue;
|
|
88
|
+
let best = 0;
|
|
89
|
+
let lag = 0;
|
|
90
|
+
for (let k = lo; k <= hi; k++) {
|
|
91
|
+
let sum = 0;
|
|
92
|
+
for (let i = 0; i < w; i++) sum += x[at + i] * x[at + i + k];
|
|
93
|
+
if (sum > best) (best = sum), (lag = k);
|
|
94
|
+
}
|
|
95
|
+
if (lag && best > 0.4 * energy) found.push(sr / lag);
|
|
96
|
+
}
|
|
97
|
+
return Math.round(quantile(found, 0.5));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** RMS per window of `ms`, both channels. */
|
|
101
|
+
export function envelope(pcm: Pcm, ms: number): number[] {
|
|
102
|
+
const w = Math.max(1, Math.round((RATE * ms) / 1000));
|
|
103
|
+
const out: number[] = [];
|
|
104
|
+
for (let at = 0; at + w <= pcm.left.length; at += w) {
|
|
105
|
+
let sum = 0;
|
|
106
|
+
for (let i = at; i < at + w; i++) sum += pcm.left[i] ** 2 + pcm.right[i] ** 2;
|
|
107
|
+
out.push(Math.sqrt(sum / (2 * w)));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function quantile(values: number[], q: number) {
|
|
113
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
114
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether a take meant to loop is really one event (a crash, a single gust) with silence around it.
|
|
119
|
+
* A loop needs sound all the way through: its quiet windows stay near its loud ones, and neither end dies out.
|
|
120
|
+
*/
|
|
121
|
+
export function loopProblem(pcm: Pcm, opts: { beats?: boolean } = {}): string | undefined {
|
|
122
|
+
const env = envelope(pcm, 100);
|
|
123
|
+
if (env.length < 10) return 'shorter than a second';
|
|
124
|
+
const median = quantile(env, 0.5);
|
|
125
|
+
// ElevenLabs makes a room hum or distant birds at -50..-65 dBFS; levelling raises them to the bed's loudness, so only
|
|
126
|
+
// takes with nothing in them fail here (a classroom hum peaking at 0.002 was a usable bed, not silence).
|
|
127
|
+
if (Math.max(...env) < 0.0005) return 'nearly silent';
|
|
128
|
+
// A thing's own loop may be a beat (a clock ticking, a drip, a blinker): even events with quiet between are its sound.
|
|
129
|
+
if (opts.beats && regularBeats(pcm)) return undefined;
|
|
130
|
+
// Most of the take far under its loudest moments: an event in silence, not a bed.
|
|
131
|
+
const steady = median / quantile(env, 0.98);
|
|
132
|
+
if (steady < 0.06) return `one event and silence, not a bed (median/peak ${steady.toFixed(2)})`;
|
|
133
|
+
const ends = Math.max(3, Math.round(env.length * 0.1));
|
|
134
|
+
const head = env.slice(0, ends).reduce((a, b) => a + b, 0) / ends;
|
|
135
|
+
const tail = env.slice(-ends).reduce((a, b) => a + b, 0) / ends;
|
|
136
|
+
if (tail < median * 0.25) return `dies out at the end (${(tail / median).toFixed(2)} of its median)`;
|
|
137
|
+
if (head < median * 0.25) return `fades in from silence (${(head / median).toFixed(2)} of its median)`;
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Four or more clear events at an even pace (gaps within 30% of their mean): ticking, not one event in silence. */
|
|
142
|
+
export function regularBeats(pcm: Pcm): boolean {
|
|
143
|
+
const env = envelope(pcm, 20);
|
|
144
|
+
const peak = quantile(env, 0.98);
|
|
145
|
+
if (peak < 0.003) return false;
|
|
146
|
+
const onsets: number[] = [];
|
|
147
|
+
let armed = true;
|
|
148
|
+
env.forEach((v, i) => {
|
|
149
|
+
if (armed && v > peak * 0.4) {
|
|
150
|
+
onsets.push(i);
|
|
151
|
+
armed = false;
|
|
152
|
+
} else if (v < peak * 0.15) armed = true;
|
|
153
|
+
});
|
|
154
|
+
if (onsets.length < 4) return false;
|
|
155
|
+
const gaps = onsets.slice(1).map((o, i) => o - onsets[i]);
|
|
156
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
157
|
+
const spread = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
|
|
158
|
+
return mean >= 5 && spread / mean < 0.3;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** How much of the sound is hiss: the energy of the change from sample to sample over the energy (white noise 2, music about 0.06). */
|
|
162
|
+
export function hiss(pcm: Pcm): number {
|
|
163
|
+
let e = 0, d = 0;
|
|
164
|
+
for (let i = 1; i < pcm.left.length; i++) {
|
|
165
|
+
const x = (pcm.left[i] + pcm.right[i]) / 2;
|
|
166
|
+
const y = (pcm.left[i - 1] + pcm.right[i - 1]) / 2;
|
|
167
|
+
e += x * x;
|
|
168
|
+
d += (x - y) ** 2;
|
|
169
|
+
}
|
|
170
|
+
return e > 0 ? d / e : 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* How much of the sound's high end is tones rather than noise: the share of loud 12 ms frames whose sample-to-sample
|
|
175
|
+
* change repeats itself (autocorrelation over 2-80 samples above 0.5). Crickets, birdsong and a whistle are tones and
|
|
176
|
+
* score high; hiss, air and rustle have no period and score near 0. `hiss` alone cannot tell them apart: a cricket
|
|
177
|
+
* at 4.5 kHz changes from sample to sample as much as white noise does.
|
|
178
|
+
*/
|
|
179
|
+
export function tonalShare(pcm: Pcm): number {
|
|
180
|
+
const N = 512;
|
|
181
|
+
const frames: Array<{ e: number; tonal: boolean }> = [];
|
|
182
|
+
for (let at = 1; at + N <= pcm.left.length; at += N) {
|
|
183
|
+
const d = new Float32Array(N);
|
|
184
|
+
for (let i = 0; i < N; i++) d[i] = (pcm.left[at + i] + pcm.right[at + i] - pcm.left[at + i - 1] - pcm.right[at + i - 1]) / 2;
|
|
185
|
+
let e = 0;
|
|
186
|
+
for (const v of d) e += v * v;
|
|
187
|
+
if (e <= 0) continue;
|
|
188
|
+
let best = 0;
|
|
189
|
+
for (let lag = 2; lag <= 80; lag++) {
|
|
190
|
+
let r = 0;
|
|
191
|
+
for (let i = lag; i < N; i++) r += d[i] * d[i - lag];
|
|
192
|
+
best = Math.max(best, r / e);
|
|
193
|
+
}
|
|
194
|
+
frames.push({ e, tonal: best > 0.5 });
|
|
195
|
+
}
|
|
196
|
+
if (!frames.length) return 0;
|
|
197
|
+
// Only frames that carry the sound count: the quiet gaps between chirps are noise floor.
|
|
198
|
+
const loud = frames.filter((f) => f.e >= frames.map((x) => x.e).sort((a, b) => a - b)[Math.floor(frames.length / 2)]);
|
|
199
|
+
return loud.filter((f) => f.tonal).length / loud.length;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Takes off what is above `hz`: two passes of a one-pole low-pass, each way. */
|
|
203
|
+
export function lowpass(pcm: Pcm, hz: number): Pcm {
|
|
204
|
+
const a = Math.exp((-2 * Math.PI * hz) / RATE);
|
|
205
|
+
const pass = (ch: Float32Array) => {
|
|
206
|
+
const out = Float32Array.from(ch);
|
|
207
|
+
for (let k = 0; k < 2; k++) {
|
|
208
|
+
let y = out[0];
|
|
209
|
+
for (let i = 0; i < out.length; i++) out[i] = y = (1 - a) * out[i] + a * y;
|
|
210
|
+
y = out[out.length - 1];
|
|
211
|
+
for (let i = out.length - 1; i >= 0; i--) out[i] = y = (1 - a) * out[i] + a * y;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
return { left: pass(pcm.left), right: pass(pcm.right) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Folds the last `fade` seconds over the start with an equal-power crossfade, so the take repeats without a seam. */
|
|
219
|
+
export function seamless(pcm: Pcm, fade = 1): Pcm {
|
|
220
|
+
const f = Math.min(Math.round(fade * RATE), Math.floor(pcm.left.length / 3));
|
|
221
|
+
const len = pcm.left.length - f;
|
|
222
|
+
const fold = (ch: Float32Array) => {
|
|
223
|
+
const out = new Float32Array(len);
|
|
224
|
+
out.set(ch.subarray(0, len));
|
|
225
|
+
// The tail [len, len+f) fades out over the head [0, f): playing out[len-1] then out[0] continues the original.
|
|
226
|
+
for (let i = 0; i < f; i++) {
|
|
227
|
+
const t = (i + 0.5) / f;
|
|
228
|
+
out[i] = ch[i] * Math.sin((t * Math.PI) / 2) + ch[len + i] * Math.cos((t * Math.PI) / 2);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
};
|
|
232
|
+
return { left: fold(pcm.left), right: fold(pcm.right) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Scales to a target RMS (dBFS) and keeps peaks under -1 dBFS, so every sound starts at a known level. */
|
|
236
|
+
export function normalise(pcm: Pcm, targetDb: number): Pcm {
|
|
237
|
+
const env = envelope(pcm, 50).filter((v) => v > 1e-4);
|
|
238
|
+
const rms = env.length ? Math.sqrt(env.reduce((a, b) => a + b * b, 0) / env.length) : 0;
|
|
239
|
+
if (!rms) return pcm;
|
|
240
|
+
let peak = 0;
|
|
241
|
+
for (let i = 0; i < pcm.left.length; i++) peak = Math.max(peak, Math.abs(pcm.left[i]), Math.abs(pcm.right[i]));
|
|
242
|
+
const gain = Math.min(10 ** (targetDb / 20) / rms, 0.89 / peak);
|
|
243
|
+
return { left: pcm.left.map((v) => v * gain), right: pcm.right.map((v) => v * gain) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Trims silence from both ends, leaving a few ms. */
|
|
247
|
+
export function trim(pcm: Pcm, floor = 0.01): Pcm {
|
|
248
|
+
const env = envelope(pcm, 5);
|
|
249
|
+
const max = Math.max(...env, 1e-9);
|
|
250
|
+
const first = env.findIndex((v) => v > max * floor);
|
|
251
|
+
const last = env.length - 1 - [...env].reverse().findIndex((v) => v > max * floor);
|
|
252
|
+
const w = Math.round(RATE * 0.005);
|
|
253
|
+
const from = Math.max(0, (first - 2) * w);
|
|
254
|
+
const to = Math.min(pcm.left.length, (last + 3) * w);
|
|
255
|
+
return { left: pcm.left.slice(from, to), right: pcm.right.slice(from, to) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Single footsteps in a take of several: onsets where the 10 ms envelope jumps above a share of its peak,
|
|
260
|
+
* each cut until just before the next one (at most `max` seconds). Returns [start, duration] in seconds.
|
|
261
|
+
*/
|
|
262
|
+
export function steps(pcm: Pcm, max = 0.45): Array<[number, number]> {
|
|
263
|
+
const ms = 10;
|
|
264
|
+
const env = envelope(pcm, ms);
|
|
265
|
+
const peak = Math.max(...env, 1e-9);
|
|
266
|
+
const onsets: number[] = [];
|
|
267
|
+
let last = -Infinity;
|
|
268
|
+
for (let i = 1; i < env.length; i++) {
|
|
269
|
+
const rising = env[i] > peak * 0.25 && env[i] > env[i - 1] * 1.8;
|
|
270
|
+
if (rising && i - last > 200 / ms) {
|
|
271
|
+
onsets.push(i);
|
|
272
|
+
last = i;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return onsets.map((on, k) => {
|
|
276
|
+
const start = Math.max(0, on - 2) * ms / 1000;
|
|
277
|
+
const next = k + 1 < onsets.length ? (onsets[k + 1] - 3) * ms / 1000 : seconds(pcm);
|
|
278
|
+
return [Math.round(start * 1000) / 1000, Math.round(Math.min(max, next - start) * 1000) / 1000] as [number, number];
|
|
279
|
+
}).filter(([, d]) => d >= 0.12);
|
|
280
|
+
}
|