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.
- package/CHANGELOG.md +32 -0
- package/dist/cli/bpm-detect.d.ts +7 -0
- package/dist/cli/bpm-detect.d.ts.map +1 -0
- package/dist/cli/bpm-detect.js +123 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +115 -0
- package/dist/cli/loudness-detect.d.ts +13 -0
- package/dist/cli/loudness-detect.d.ts.map +1 -0
- package/dist/cli/loudness-detect.js +96 -0
- package/dist/vite/code-block.d.ts.map +1 -1
- package/dist/vite/code-block.js +26 -5
- package/dist/vite/layout-editor.js +1 -1
- package/dist/vite/mdx-video.test.js +0 -79
- package/dist/vite/player-page.d.ts.map +1 -1
- package/dist/vite/player-page.js +6 -94
- package/dist/vite/store-hooks.d.ts +2 -0
- package/dist/vite/store-hooks.d.ts.map +1 -1
- package/dist/vite/store-hooks.js +8 -0
- package/dist/vite/store.d.ts +5 -0
- package/dist/vite/store.d.ts.map +1 -1
- package/dist/vite/store.js +5 -0
- package/dist/vite/tweakpane-hook.d.ts +6 -3
- package/dist/vite/tweakpane-hook.d.ts.map +1 -1
- package/dist/vite/tweakpane-hook.js +27 -16
- package/dist/vite/vite-plugin.d.ts +0 -6
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +1 -92
- package/package.json +9 -9
- package/skills/egaki/SKILL.md +26 -224
- package/src/cli/bpm-detect.ts +165 -0
- package/src/cli/cli.ts +149 -0
- package/src/cli/loudness-detect.ts +129 -0
- package/src/vite/code-block.tsx +28 -6
- package/src/vite/layout-editor.tsx +1 -1
- package/src/vite/mdx-video.test.tsx +0 -94
- package/src/vite/player-page.tsx +28 -117
- package/src/vite/store-hooks.ts +11 -0
- package/src/vite/store.ts +10 -0
- package/src/vite/tweakpane-hook.tsx +71 -24
- package/src/vite/vite-plugin.ts +1 -91
package/skills/egaki/SKILL.md
CHANGED
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: egaki
|
|
3
|
+
repo: remorses/egaki
|
|
3
4
|
description: >
|
|
4
|
-
AI image and video generation CLI
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
Two authentication modes:
|
|
36
38
|
|
|
37
|
-
1. Egaki subscription key (recommended
|
|
38
|
-
2. Provider API keys (Google, OpenAI, Fal,
|
|
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
|
|
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
|
|
53
|
-
- **Non-interactive (piped/scripted):**
|
|
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
|
-
##
|
|
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
|
|
269
|
-
|
|
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
|