egaki 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/bpm-detect.d.ts +7 -0
  3. package/dist/cli/bpm-detect.d.ts.map +1 -0
  4. package/dist/cli/bpm-detect.js +123 -0
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +115 -0
  7. package/dist/cli/loudness-detect.d.ts +13 -0
  8. package/dist/cli/loudness-detect.d.ts.map +1 -0
  9. package/dist/cli/loudness-detect.js +96 -0
  10. package/dist/vite/code-block.d.ts.map +1 -1
  11. package/dist/vite/code-block.js +26 -5
  12. package/dist/vite/layout-editor.js +1 -1
  13. package/dist/vite/mdx-video.test.js +0 -79
  14. package/dist/vite/player-page.d.ts.map +1 -1
  15. package/dist/vite/player-page.js +6 -94
  16. package/dist/vite/store-hooks.d.ts +2 -0
  17. package/dist/vite/store-hooks.d.ts.map +1 -1
  18. package/dist/vite/store-hooks.js +8 -0
  19. package/dist/vite/store.d.ts +5 -0
  20. package/dist/vite/store.d.ts.map +1 -1
  21. package/dist/vite/store.js +5 -0
  22. package/dist/vite/tweakpane-hook.d.ts +6 -3
  23. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  24. package/dist/vite/tweakpane-hook.js +27 -16
  25. package/dist/vite/vite-plugin.d.ts +0 -6
  26. package/dist/vite/vite-plugin.d.ts.map +1 -1
  27. package/dist/vite/vite-plugin.js +1 -92
  28. package/package.json +9 -9
  29. package/skills/egaki/SKILL.md +26 -224
  30. package/src/cli/bpm-detect.ts +165 -0
  31. package/src/cli/cli.ts +149 -0
  32. package/src/cli/loudness-detect.ts +129 -0
  33. package/src/vite/code-block.tsx +28 -6
  34. package/src/vite/layout-editor.tsx +1 -1
  35. package/src/vite/mdx-video.test.tsx +0 -94
  36. package/src/vite/player-page.tsx +28 -117
  37. package/src/vite/store-hooks.ts +11 -0
  38. package/src/vite/store.ts +10 -0
  39. package/src/vite/tweakpane-hook.tsx +71 -24
  40. package/src/vite/vite-plugin.ts +1 -91
@@ -1,43 +1,45 @@
1
1
  ---
2
2
  name: egaki
3
+ repo: remorses/egaki
3
4
  description: >
4
- AI image and video generation CLI. Use this skill to install egaki, configure
5
- auth, run help commands, and generate images or videos with provider keys or
6
- an Egaki subscription.
5
+ AI image and video generation CLI and MDX-to-video framework built on Remotion.
6
+ Use this skill to install egaki, configure auth, generate images/videos, create
7
+ MDX video projects with animation primitives (Opacity, Scale, TranslateX, Blur),
8
+ easing presets (EASE, springFromDuration, dspring, cubicBezier), LayoutTransition,
9
+ captions, voice cloning, TTS, Server components, and media components. ALWAYS load
10
+ this skill when the user mentions egaki, runs egaki commands, edits egaki MDX video
11
+ files, or works in a project with an egaki vite plugin.
7
12
  ---
8
13
 
9
14
  # egaki
10
15
 
11
- Generate AI images and videos from the terminal.
12
- Use this for text-to-image, image editing, mask-based edits, text-to-video,
13
- image-to-video, audio stem separation, and model discovery.
14
-
15
- ## Install
16
+ Every time you work with egaki, you MUST fetch the latest README:
16
17
 
17
18
  ```bash
18
- pnpm add -g egaki
19
+ curl -s https://raw.githubusercontent.com/remorses/egaki/main/README.md # NEVER pipe to head/tail, read the full output
19
20
  ```
20
21
 
21
- ## Always check help first
22
+ The README is the single source of truth for all user-facing docs: CLI usage,
23
+ MDX video syntax, animation primitives, easing presets, LayoutTransition,
24
+ captions, voice cloning, media components, CSS rules, and project setup.
22
25
 
23
- Run the full help output before using commands:
26
+ ## Always check help first
24
27
 
25
28
  ```bash
26
- egaki --help
29
+ egaki --help # NEVER pipe to head/tail, read the full output
27
30
  ```
28
31
 
29
- Do not truncate help output with `head`.
30
-
31
- For subcommand details: `egaki <command> --help` (e.g. `egaki image --help`, `egaki video --help`, `egaki login --help`)
32
+ For subcommand details: `egaki <command> --help` (e.g. `egaki image --help`,
33
+ `egaki video --help`, `egaki login --help`)
32
34
 
33
35
  ## Auth options
34
36
 
35
- You can authenticate in two ways:
37
+ Two authentication modes:
36
38
 
37
- 1. Egaki subscription key (recommended — all models, one key)
38
- 2. Provider API keys (Google, OpenAI, Fal, Replicate) via `egaki login`
39
+ 1. **Egaki subscription key** (recommended, all models, one key)
40
+ 2. **Provider API keys** (Google, OpenAI, Fal, xAI, etc.) via `egaki login`
39
41
 
40
- If using Egaki subscription, set it up first with `egaki subscribe`, then store
42
+ If using Egaki subscription, set it up with `egaki subscribe`, then store
41
43
  the key with `egaki login --provider egaki --key egaki_...`.
42
44
 
43
45
  ## Login behavior for remote agents
@@ -49,222 +51,22 @@ to the user so they can complete auth interactively.
49
51
 
50
52
  The `--model` / `-m` flag is **optional** on both `egaki image` and `egaki video`.
51
53
 
52
- - **Interactive (TTY):** omitting `--model` shows a picker with popular models
53
- - **Non-interactive (piped/scripted):** omitting `--model` uses a sensible default
54
+ - **Interactive (TTY):** omitting `--model` shows a picker
55
+ - **Non-interactive (piped/scripted):** uses a sensible default
54
56
 
55
57
  Agents should always pass `-m` explicitly to avoid the interactive picker.
56
58
 
57
59
  ### Preferred image models
58
60
 
59
- When the user does not specify a model, prefer these in order:
60
-
61
61
  1. **`gpt-image-1`** (or `gpt-image-2` / `chatgpt-image-latest` if available)
62
62
  2. **`grok-imagine-image`** (xAI Grok)
63
63
  3. **`nano-banana-pro-preview`**
64
64
 
65
- Always try the first available model. Fall back to the next if auth is missing
66
- for the preferred one.
67
-
68
65
  ### Preferred video models
69
66
 
70
- When the user does not specify a video model, prefer:
71
-
72
67
  1. **`grok-imagine-video-1.5`** (xAI Grok)
73
68
 
74
- ## Example commands
75
-
76
- ```bash
77
- # configure key interactively
78
- egaki login
79
-
80
- # show login status
81
- egaki login --show
82
-
83
- # subscribe to Egaki for all supported models
84
- egaki subscribe
85
-
86
- # check subscription usage
87
- egaki usage
88
-
89
- # generate an image (interactive model picker if TTY)
90
- egaki image "a watercolor fox reading a map" -o fox.png
91
-
92
- # select a model explicitly
93
- egaki image "isometric floating city, soft colors" -m imagen-4.0-generate-001 -o city.png
94
-
95
- # edit an existing image (local file or URL)
96
- egaki image "add a red scarf and make it winter" --input portrait.jpg -o portrait-winter.png
97
- egaki image "turn this into a manga panel" --input https://example.com/photo.jpg -o manga.png
98
-
99
- # inpainting with a mask
100
- egaki image "replace the sky with a dramatic sunset" --input scene.png --mask mask.png -o scene-sunset.png
101
-
102
- # generate a video — use a 5 minute timeout, video generation is slow
103
- egaki video "a paper boat drifting on a calm lake at sunrise" -o boat.mp4
104
-
105
- # generate a video with a specific model
106
- egaki video "timelapse of a stormy sea, cinematic" -m google/veo-3.1-fast-generate-001 --duration 6 -o storm.mp4
107
-
108
- # cheap video model
109
- egaki video "a cat walking on a rooftop at night" -m klingai/kling-v2.5-turbo-t2v --duration 5 -o cat.mp4
110
-
111
- # image-to-video (model must support i2v)
112
- egaki video "slowly animate the clouds" --input photo.jpg -m klingai/kling-v2.6-i2v -o animated.mp4
113
-
114
- # discover all models (image + video)
115
- egaki models
116
-
117
- # filter by type
118
- egaki models --type video
119
- egaki models --type image
120
- ```
121
-
122
- ## Audio stem separation (demucs)
123
-
124
- Separate audio into individual stems using Meta's **Demucs** model via fal.ai.
125
- Requires a fal.ai API key (`egaki login --provider fal`).
126
-
127
- The default model `htdemucs_6s` splits into 6 stems: **vocals, drums, bass,
128
- guitar, piano, other**. By default only vocals and other (background) are
129
- extracted.
130
-
131
- ```bash
132
- # Extract vocals and background (default)
133
- egaki demucs song.mp3
134
-
135
- # Extract only vocals
136
- egaki demucs song.mp3 --stems vocals
137
-
138
- # All 6 stems as WAV
139
- egaki demucs song.mp3 --stems vocals,drums,bass,other,guitar,piano --output-format wav
140
-
141
- # Save to a directory
142
- egaki demucs song.mp3 -o stems/
143
-
144
- # Higher quality (slower, more random shifts)
145
- egaki demucs song.mp3 --shifts 5
146
-
147
- # Read from stdin (e.g. ffmpeg pipe)
148
- ffmpeg -i video.mp4 -f mp3 - | egaki demucs --stdin -o stems/
149
- ```
150
-
151
- Output files are named `{input}-{stem}.{format}` (e.g. `song-vocals.mp3`,
152
- `song-other.mp3`).
153
-
154
- **Model variants:** `htdemucs_6s` (default, 6 stems), `htdemucs` (4 stems),
155
- `htdemucs_ft` (fine-tuned 4 stems), `hdemucs_mmi` (4 stems), `mdx` (4 stems,
156
- MDX architecture), `mdx_extra` (4 stems, MDX), `mdx_q` (4 stems, quantized),
157
- `mdx_extra_q` (4 stems, quantized). Guitar and piano stems are only available
158
- with `htdemucs_6s`.
159
-
160
- ## Voice cloning
161
-
162
- Clone a voice from any audio. Returns a voice ID for use with `egaki speech --voice <id>`.
163
-
164
- Two providers:
165
- - **Cartesia** (default): fast, free, best with short clean clips (5-10s)
166
- - **ElevenLabs**: accepts longer audio (1-3 min), has `--remove-background-noise`
167
-
168
- ### Preparing the audio
169
-
170
- Raw recordings, YouTube clips, podcasts, and interviews almost always have
171
- background music, room noise, or multiple speakers. Feeding dirty audio to the
172
- clone API produces a muddy, inaccurate voice. Always clean it up first.
173
-
174
- **Step 1: Isolate vocals with demucs.**
175
- Strip everything except the target speaker's voice. This removes background
176
- music, ambient noise, sound effects, and other speakers.
177
-
178
- ```bash
179
- egaki demucs recording.mp3 --stems vocals
180
- # outputs: recording-vocals.mp3
181
- ```
182
-
183
- For higher quality separation (slower), add `--shifts 5`.
184
-
185
- **Step 2: Transcribe to find the best segment.**
186
- Run transcription to get word-level timestamps. Look for a segment where the
187
- speaker says a **complete phrase or sentence** clearly, with no hesitations,
188
- filler words ("um", "uh"), crosstalk, or long pauses.
189
-
190
- ```bash
191
- egaki transcribe recording-vocals.mp3
192
- ```
193
-
194
- Review the `wordTimestamps` array in the JSON output. Pick a window that is:
195
- - **Cartesia**: 5-10 seconds (max 10s). One clean sentence is ideal.
196
- - **ElevenLabs**: 30s-3 min. Multiple sentences give better results.
197
-
198
- The segment should start and end mid-speech, not on silence. The clone mimics
199
- the energy and pacing of the source, so pick a segment that matches the tone
200
- you want (calm narration, energetic presentation, conversational, etc.).
201
-
202
- **Step 3: Trim with ffmpeg.**
203
- Use the timestamps from step 2 to cut the exact segment. The `-c copy` flag
204
- avoids re-encoding so there's no quality loss.
205
-
206
- ```bash
207
- # Cartesia: trim a 10s clip (timestamps from transcription)
208
- ffmpeg -i recording-vocals.mp3 -ss 12.5 -to 22.0 -c copy clip.mp3
209
-
210
- # ElevenLabs: trim a longer segment
211
- ffmpeg -i recording-vocals.mp3 -ss 5.0 -to 65.0 -c copy clip-long.mp3
212
- ```
213
-
214
- ### Cloning
215
-
216
- ```bash
217
- # Cartesia (default, fast, free)
218
- egaki voice clone clip.mp3 --name "Speaker Name"
219
-
220
- # ElevenLabs (longer clips, optional noise removal)
221
- egaki voice clone clip-long.mp3 --name "Speaker Name" --provider elevenlabs
222
-
223
- # ElevenLabs with AI noise removal (if source still has some noise)
224
- egaki voice clone clip.mp3 --name "Speaker Name" --provider elevenlabs --remove-background-noise
225
-
226
- # Specify language for non-English Cartesia clones (ignored by ElevenLabs)
227
- egaki voice clone clip.mp3 --name "Narrador" --language es
228
- ```
229
-
230
- The command prints the voice ID on success. Save it.
231
-
232
- ### Using the cloned voice
233
-
234
- ```bash
235
- egaki speech "Any text you want spoken in the cloned voice." --voice <voice-id> -m sonic-3.5
236
- ```
237
-
238
- The voice ID works with any Cartesia TTS model (`sonic-3`, `sonic-3.5`) or
239
- ElevenLabs model (`eleven_v3`, `eleven_flash_v2_5`) depending on which
240
- provider you cloned with. Cartesia voice IDs only work with Cartesia models
241
- and vice versa.
242
-
243
- ### Full pipeline example
244
-
245
- ```bash
246
- # 1. Download audio (e.g. from YouTube)
247
- yt-dlp -x --audio-format mp3 -o source.mp3 'https://youtube.com/watch?v=...'
248
-
249
- # 2. Isolate vocals
250
- egaki demucs source.mp3 --stems vocals
251
-
252
- # 3. Transcribe to find timestamps
253
- egaki transcribe source-vocals.mp3
254
-
255
- # 4. Trim to a clean 10s segment (adjust -ss and -to from timestamps)
256
- ffmpeg -i source-vocals.mp3 -ss 0 -to 10 -c copy clip.mp3
257
-
258
- # 5. Clone
259
- egaki voice clone clip.mp3 --name "Narrator"
260
- # Voice ID: abc123-def456-...
261
-
262
- # 6. Generate speech
263
- egaki speech "Your text here." --voice abc123-def456-... -m sonic-3.5 -o output.wav
264
- ```
265
-
266
- ## Video generation note for agents
69
+ ## Video generation note
267
70
 
268
- Video generation can be very slow — some models take 1–3 minutes per request.
269
- Always use a command timeout of **at least 5 minutes** when invoking `egaki video`
270
- from automation or agent workflows.
71
+ Video generation can be very slow (1-3 minutes per request). Always use a
72
+ command timeout of **at least 5 minutes** from automation or agent workflows.
@@ -0,0 +1,165 @@
1
+ // BPM detection for audio files using Web Audio API.
2
+ // Ported from https://github.com/tornqvist/bpm-detective
3
+ // Uses node-web-audio-api (native NAPI Rust binding) as an optional dependency.
4
+ // If missing, returns an Error with install instructions.
5
+
6
+ interface BpmResult {
7
+ bpm: number
8
+ intervalInSeconds: number
9
+ }
10
+
11
+ // Cached module reference set by detectBpm() before calling detect().
12
+ // This avoids require() in ESM and avoids passing the module through every function.
13
+ let webAudioMod: any = null
14
+
15
+ export async function detectBpm(buffer: ArrayBuffer): Promise<Error | BpmResult> {
16
+ try {
17
+ webAudioMod = await import('node-web-audio-api')
18
+ } catch {
19
+ return new Error(
20
+ 'node-web-audio-api is required for BPM detection. Install it:\n\n' +
21
+ ' pnpm add node-web-audio-api\n',
22
+ )
23
+ }
24
+
25
+ const context = new webAudioMod.AudioContext()
26
+ try {
27
+ const data: AudioBuffer = await context.decodeAudioData(buffer)
28
+ const bpm = detect(data)
29
+ const intervalInSeconds = 60 / bpm
30
+ return { bpm, intervalInSeconds }
31
+ } catch (err) {
32
+ if (err instanceof Error) return err
33
+ return new Error(String(err))
34
+ } finally {
35
+ await context.close().catch(() => {})
36
+ }
37
+ }
38
+
39
+ // ─── algorithm ──────────────────────────────────────────────────────────────
40
+
41
+ interface TempoCount {
42
+ tempo: number
43
+ count: number
44
+ }
45
+
46
+ interface IntervalCount {
47
+ interval: number
48
+ count: number
49
+ }
50
+
51
+ function detect(buffer: AudioBuffer): number {
52
+ const source = getLowPassSource(buffer)
53
+ source.start(0)
54
+
55
+ const channelData = source.buffer!.getChannelData(0)
56
+ const peaks = findPeaks(channelData)
57
+ const intervals = identifyIntervals(peaks)
58
+ const tempoCounts = groupByTempo(buffer.sampleRate, intervals)
59
+ return getTopCandidate(tempoCounts)
60
+ }
61
+
62
+ function getTopCandidate(candidates: TempoCount[]): number {
63
+ return candidates.sort((a, b) => b.count - a.count).splice(0, 5)[0]!.tempo
64
+ }
65
+
66
+ function getLowPassSource(buffer: AudioBuffer): AudioBufferSourceNode {
67
+ const { length, numberOfChannels, sampleRate } = buffer
68
+ const context = new webAudioMod.OfflineAudioContext(numberOfChannels, length, sampleRate)
69
+ const source = context.createBufferSource()
70
+ source.buffer = buffer
71
+
72
+ const filter = context.createBiquadFilter()
73
+ filter.type = 'lowpass'
74
+
75
+ source.connect(filter)
76
+ filter.connect(context.destination)
77
+
78
+ return source
79
+ }
80
+
81
+ function findPeaks(data: Float32Array): number[] {
82
+ let peaks: number[] = []
83
+ let threshold = 0.9
84
+ const minThreshold = 0.3
85
+ const minPeaks = 15
86
+
87
+ while (peaks.length < minPeaks && threshold >= minThreshold) {
88
+ peaks = findPeaksAtThreshold(data, threshold)
89
+ threshold -= 0.05
90
+ }
91
+
92
+ if (peaks.length < minPeaks) {
93
+ throw new Error('Could not find enough samples for a reliable BPM detection.')
94
+ }
95
+
96
+ return peaks
97
+ }
98
+
99
+ function findPeaksAtThreshold(data: Float32Array, threshold: number): number[] {
100
+ const peaks: number[] = []
101
+
102
+ for (let i = 0, l = data.length; i < l; i += 1) {
103
+ if (data[i]! > threshold) {
104
+ peaks.push(i)
105
+ // Skip forward ~1/4s to get past this peak
106
+ i += 10000
107
+ }
108
+ }
109
+
110
+ return peaks
111
+ }
112
+
113
+ function identifyIntervals(peaks: number[]): IntervalCount[] {
114
+ const intervals: IntervalCount[] = []
115
+
116
+ peaks.forEach((peak, index) => {
117
+ for (let i = 0; i < 10; i += 1) {
118
+ const interval = peaks[index + i]! - peak
119
+
120
+ const foundInterval = intervals.some((ic) => {
121
+ if (ic.interval === interval) {
122
+ ic.count += 1
123
+ return true
124
+ }
125
+ return false
126
+ })
127
+
128
+ if (!foundInterval) {
129
+ intervals.push({ interval, count: 1 })
130
+ }
131
+ }
132
+ })
133
+
134
+ return intervals
135
+ }
136
+
137
+ function groupByTempo(sampleRate: number, intervalCounts: IntervalCount[]): TempoCount[] {
138
+ const tempoCounts: TempoCount[] = []
139
+
140
+ intervalCounts.forEach((intervalCount) => {
141
+ if (intervalCount.interval !== 0) {
142
+ let theoreticalTempo = 60 / (intervalCount.interval / sampleRate)
143
+
144
+ // Adjust to fit within the 90-180 BPM range
145
+ while (theoreticalTempo < 90) theoreticalTempo *= 2
146
+ while (theoreticalTempo > 180) theoreticalTempo /= 2
147
+
148
+ theoreticalTempo = Math.round(theoreticalTempo)
149
+
150
+ const foundTempo = tempoCounts.some((tc) => {
151
+ if (tc.tempo === theoreticalTempo) {
152
+ tc.count += intervalCount.count
153
+ return true
154
+ }
155
+ return false
156
+ })
157
+
158
+ if (!foundTempo) {
159
+ tempoCounts.push({ tempo: theoreticalTempo, count: intervalCount.count })
160
+ }
161
+ }
162
+ })
163
+
164
+ return tempoCounts
165
+ }
package/src/cli/cli.ts CHANGED
@@ -87,6 +87,12 @@ import {
87
87
  cloneVoiceUncached as cloneVoice,
88
88
  type CloneProvider,
89
89
  } from './clone-generate.js'
90
+ import {
91
+ detectBpm,
92
+ } from './bpm-detect.js'
93
+ import {
94
+ detectLoudness,
95
+ } from './loudness-detect.js'
90
96
 
91
97
  const cli = goke('egaki')
92
98
 
@@ -1006,6 +1012,149 @@ cli
1006
1012
  }
1007
1013
  })
1008
1014
 
1015
+ // ─── bpm command ─────────────────────────────────────────────────────────────
1016
+
1017
+ cli
1018
+ .command(
1019
+ 'bpm [audio]',
1020
+ dedent`
1021
+ Detect the BPM (beats per minute) of an audio file.
1022
+ Runs locally using peak detection, no API call needed.
1023
+ Requires node-web-audio-api as an optional dependency.
1024
+ `,
1025
+ )
1026
+ .option(
1027
+ '--stdin',
1028
+ 'Read audio from stdin instead of a file path',
1029
+ )
1030
+ .option(
1031
+ '--json',
1032
+ 'Output result as JSON to stdout',
1033
+ )
1034
+ .example('# Detect BPM of a song')
1035
+ .example('egaki bpm song.mp3')
1036
+ .example('# Pipe audio from another command')
1037
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki bpm --stdin')
1038
+ .example('# Get JSON output')
1039
+ .example('egaki bpm track.wav --json')
1040
+ .action(async (audioPath = '', options) => {
1041
+ if (!options.stdin && !audioPath) {
1042
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'))
1043
+ process.exit(1)
1044
+ }
1045
+
1046
+ let audioData: ArrayBuffer
1047
+
1048
+ if (options.stdin) {
1049
+ const chunks: Buffer[] = []
1050
+ for await (const chunk of process.stdin) {
1051
+ chunks.push(chunk)
1052
+ }
1053
+ const buf = Buffer.concat(chunks)
1054
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
1055
+ } else {
1056
+ const resolved = path.resolve(audioPath)
1057
+ if (!fs.existsSync(resolved)) {
1058
+ console.error(pc.red(`File not found: ${resolved}`))
1059
+ process.exit(1)
1060
+ }
1061
+ const buf = fs.readFileSync(resolved)
1062
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
1063
+ }
1064
+
1065
+ if (!options.json) {
1066
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.byteLength / 1024).toFixed(1)} KB)`))
1067
+ console.error(pc.cyan('Detecting BPM...'))
1068
+ }
1069
+
1070
+ const result = await detectBpm(audioData)
1071
+
1072
+ if (result instanceof Error) {
1073
+ console.error(pc.red(result.message))
1074
+ process.exit(1)
1075
+ }
1076
+
1077
+ if (options.json) {
1078
+ console.log(JSON.stringify(result, null, 2))
1079
+ } else {
1080
+ console.error(pc.green(`BPM: ${result.bpm}`))
1081
+ console.error(pc.dim(`Interval: ${result.intervalInSeconds.toFixed(3)}s`))
1082
+ }
1083
+ })
1084
+
1085
+ // ─── loudness command ────────────────────────────────────────────────────────
1086
+
1087
+ cli
1088
+ .command(
1089
+ 'loudness [audio]',
1090
+ dedent`
1091
+ Measure the perceived loudness (LUFS) of an audio file.
1092
+ Uses K-weighting and two-pass gating inspired by EBU R128.
1093
+ Runs locally, no API call needed.
1094
+ Requires node-web-audio-api as an optional dependency.
1095
+ `,
1096
+ )
1097
+ .option(
1098
+ '--stdin',
1099
+ 'Read audio from stdin instead of a file path',
1100
+ )
1101
+ .option(
1102
+ '--json',
1103
+ 'Output result as JSON to stdout',
1104
+ )
1105
+ .example('# Measure loudness of a song')
1106
+ .example('egaki loudness song.mp3')
1107
+ .example('# Pipe audio from another command')
1108
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki loudness --stdin')
1109
+ .example('# Get JSON output')
1110
+ .example('egaki loudness track.wav --json')
1111
+ .action(async (audioPath = '', options) => {
1112
+ if (!options.stdin && !audioPath) {
1113
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'))
1114
+ process.exit(1)
1115
+ }
1116
+
1117
+ let audioData: ArrayBuffer
1118
+
1119
+ if (options.stdin) {
1120
+ const chunks: Buffer[] = []
1121
+ for await (const chunk of process.stdin) {
1122
+ chunks.push(chunk)
1123
+ }
1124
+ const buf = Buffer.concat(chunks)
1125
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
1126
+ } else {
1127
+ const resolved = path.resolve(audioPath)
1128
+ if (!fs.existsSync(resolved)) {
1129
+ console.error(pc.red(`File not found: ${resolved}`))
1130
+ process.exit(1)
1131
+ }
1132
+ const buf = fs.readFileSync(resolved)
1133
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
1134
+ }
1135
+
1136
+ if (!options.json) {
1137
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.byteLength / 1024).toFixed(1)} KB)`))
1138
+ console.error(pc.cyan('Measuring loudness...'))
1139
+ }
1140
+
1141
+ const result = await detectLoudness(audioData)
1142
+
1143
+ if (result instanceof Error) {
1144
+ console.error(pc.red(result.message))
1145
+ process.exit(1)
1146
+ }
1147
+
1148
+ if (options.json) {
1149
+ console.log(JSON.stringify(result, null, 2))
1150
+ } else {
1151
+ console.error(pc.green(`Integrated: ${result.integrated.toFixed(1)} LUFS`))
1152
+ console.error(pc.dim(`Max: ${result.max.toFixed(1)} LUFS`))
1153
+ console.error(pc.dim(`Min: ${result.min.toFixed(1)} LUFS`))
1154
+ console.error(pc.dim(`Range: ${result.range.toFixed(1)} LU`))
1155
+ }
1156
+ })
1157
+
1009
1158
  // ─── voice clone command ─────────────────────────────────────────────────────
1010
1159
 
1011
1160
  cli