sonilo 0.3.0 → 0.5.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/README.md CHANGED
@@ -9,6 +9,25 @@ Works in Node.js ≥ 18 and modern browsers. Zero runtime dependencies.
9
9
  npm install sonilo
10
10
  ```
11
11
 
12
+ ## Authentication
13
+
14
+ Create an API key in your [Sonilo dashboard](https://platform.sonilo.com/dashboard/api-keys),
15
+ then give it to the client either as an environment variable (recommended) or
16
+ inline:
17
+
18
+ ```bash
19
+ export SONILO_API_KEY=sk_...
20
+ ```
21
+
22
+ ```ts
23
+ const sonilo = new SoniloClient(); // reads SONILO_API_KEY
24
+ // or pass it directly:
25
+ const sonilo = new SoniloClient({ apiKey: "sk_..." });
26
+ ```
27
+
28
+ Keep your key secret — use it only server-side, never commit it, and prefer the
29
+ environment variable over hardcoding it.
30
+
12
31
  ## Quickstart
13
32
 
14
33
  ```ts
@@ -36,12 +55,14 @@ const track = await sonilo.videoToMusic.generate({
36
55
  await sonilo.videoToMusic.generate({ videoUrl: "https://example.com/clip.mp4" });
37
56
  ```
38
57
 
39
- ### Vocal isolation (async)
58
+ ### Preserve speech (async)
40
59
 
41
- Splitting out a vocals-only stem requires the async task API the plain
42
- stream above doesn't support it. Submit with `isolateVocals: true` (this
43
- implies `mode: "async"` if you don't set `mode` yourself) and poll with
44
- `client.tasks.wait<MusicTaskResult>()`:
60
+ Set `preserveSpeech: true` to keep the source speech/vocals in the result.
61
+ This requires the async task API — the plain stream above doesn't support it
62
+ so it implies `mode: "async"` if you don't set `mode` yourself. Submit, then
63
+ poll with `client.tasks.wait<MusicTaskResult>()`. The result carries the
64
+ generated `audio` plus a separate speech stem (`vocals`) and a `mux` (the
65
+ generated music mixed with the preserved speech):
45
66
 
46
67
  ```ts
47
68
  import { SoniloClient, download } from "sonilo";
@@ -52,16 +73,108 @@ const client = new SoniloClient();
52
73
  const task = await client.videoToMusic.submit({
53
74
  video: "./my_video.mp4",
54
75
  prompt: "upbeat, energetic",
55
- isolateVocals: true,
76
+ preserveSpeech: true,
56
77
  });
57
78
  const result = await client.tasks.wait<MusicTaskResult>(task.task_id);
58
79
 
59
- // `audio` is always an array for async video-to-music (one entry per
60
- // output stream); `vocals` and `mux` are only present with isolateVocals.
80
+ // `audio` is always an array for async video-to-music (one entry per output
81
+ // stream); `vocals` and `mux` are only present when preserveSpeech is set.
61
82
  await writeFile("mix.m4a", await download(result.audio[0]!));
62
83
  await writeFile("vocals.m4a", await download(result.vocals!));
63
84
  ```
64
85
 
86
+ ### Ducking, speech & output format (async video-to-music)
87
+
88
+ The async `submit()` path also accepts:
89
+
90
+ - `preserveSpeech` — keep the source speech/vocals in the result (see
91
+ [Preserve speech](#preserve-speech-async) above).
92
+ - `ducking` — duck the generated music under the source voice. It is **on by
93
+ default** in async mode; pass `ducking: false` to opt out. When it runs, the
94
+ result gains a `ducked` array alongside `audio`.
95
+ - `outputFormat` — `"m4a"` (default) or `"wav"` (requires async mode).
96
+
97
+ ```ts
98
+ const task = await client.videoToMusic.submit({
99
+ video: "./my_video.mp4",
100
+ preserveSpeech: true,
101
+ outputFormat: "wav",
102
+ // ducking is on by default in async — set `false` to disable
103
+ });
104
+ const result = await client.tasks.wait<MusicTaskResult>(task.task_id);
105
+ if (result.ducked) {
106
+ await writeFile("ducked.wav", await download(result.ducked[0]!));
107
+ }
108
+ ```
109
+
110
+ ## Video to video
111
+
112
+ Generate a soundtrack or sound effects and get back a **re-hosted video** with
113
+ the audio muxed in — not just an audio file. Both endpoints are async; poll to
114
+ a `VideoResult`:
115
+
116
+ ```ts
117
+ import { SoniloClient, download } from "sonilo";
118
+ import { writeFile } from "node:fs/promises";
119
+
120
+ const client = new SoniloClient();
121
+
122
+ // Score music into the video (optionally keep the original speech)
123
+ const music = await client.videoToVideoMusic.generate({
124
+ video: "./my_video.mp4", // Node path; File/Blob in the browser, or `videoUrl`
125
+ prompt: "cinematic orchestral swell",
126
+ preserveSpeech: true,
127
+ });
128
+ await writeFile("scored.mp4", await download(music.video!));
129
+
130
+ // Sound effects for the video, optionally per time segment
131
+ const sfx = await client.videoToVideoSfx.generate({
132
+ video: "./my_video.mp4",
133
+ segments: [{ start: 0, end: 2, prompt: "footsteps on gravel" }],
134
+ });
135
+ await writeFile("with_sfx.mp4", await download(sfx.video!));
136
+ ```
137
+
138
+ ## Video to sound
139
+
140
+ `videoToSound` and `videoToVideoSound` generate a music bed and sound effects
141
+ for the same clip and return them mixed into a single soundtrack — one call,
142
+ one charge, instead of chaining two requests. `videoToSound` returns the mixed
143
+ audio; `videoToVideoSound` returns the source video with that audio muxed in.
144
+ Both are async-only, and both accept the same options.
145
+
146
+ ```ts
147
+ import { SoniloClient, download } from "sonilo";
148
+ import { writeFile } from "node:fs/promises";
149
+
150
+ const client = new SoniloClient();
151
+
152
+ const result = await client.videoToSound.generate({
153
+ videoUrl: "https://example.com/clip.mp4",
154
+ musicPrompt: "uplifting orchestral score",
155
+ sfxPrompt: "match the on-screen action",
156
+ });
157
+
158
+ await writeFile("soundtrack.wav", await download(result.output_url));
159
+ ```
160
+
161
+ The mixed result is `output_url` (`output_type` is `"audio"` here, `"video"`
162
+ for `videoToVideoSound`). The individual stems come back alongside it, so you
163
+ can re-balance the mix yourself:
164
+
165
+ ```ts
166
+ await writeFile("music.m4a", await download(result.music));
167
+ await writeFile("sfx.wav", await download(result.sfx));
168
+ ```
169
+
170
+ `preserveSpeech: true` keeps the speech from the source video, and `ducking`
171
+ (on by default) dips the music under it — pass `ducking: false` to opt out.
172
+ `segments` takes the same `{ start, end, prompt }` list as `videoToSfx`.
173
+ Input videos may be at most 180 seconds long.
174
+
175
+ Use `submit()` instead of `generate()` to get a `task_id` back immediately and
176
+ poll it yourself with `client.tasks.wait<SoundResult>(taskId)`.
177
+
65
178
  ## Configuration
66
179
 
67
180
  ```ts
@@ -143,6 +256,18 @@ const result = await client.tasks.wait(task.task_id, { pollInterval: 2000, timeo
143
256
  task keeps running server-side and can still be polled afterwards. Result URLs
144
257
  are presigned and expire; download promptly or re-fetch via `tasks.get`.
145
258
 
259
+ ## Free trial
260
+
261
+ Accounts created through self-serve signup start with free runs on every
262
+ endpoint — no card required:
263
+
264
+ | Free runs | Endpoints |
265
+ | --- | --- |
266
+ | 2 each | text-to-music, text-to-sfx, audio-ducking |
267
+ | 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound |
268
+
269
+ Once an endpoint's free runs are used up, calls to it bill at the normal rate.
270
+
146
271
  ## Account
147
272
 
148
273
  ```ts
package/dist/index.cjs CHANGED
@@ -336,6 +336,32 @@ var TextToMusic = class {
336
336
  generate(params) {
337
337
  return collectTrack(this.stream(params));
338
338
  }
339
+ /**
340
+ * Submit an async text-to-music task; poll with
341
+ * `client.tasks.wait<MusicTaskResult>(task.task_id)`. Required for
342
+ * `outputFormat: "wav"`. `stream()`/`generate()` remain the streaming path.
343
+ */
344
+ async submit(params) {
345
+ const mode = params.mode ?? "async";
346
+ if (mode !== "async") {
347
+ throw new SoniloError('submit() requires mode: "async"');
348
+ }
349
+ const form = new FormData();
350
+ form.set("prompt", params.prompt);
351
+ form.set("duration", String(params.duration));
352
+ if (params.segments !== void 0) {
353
+ form.set("segments", JSON.stringify(params.segments));
354
+ }
355
+ form.set("mode", mode);
356
+ if (params.outputFormat !== void 0) {
357
+ form.set("output_format", params.outputFormat);
358
+ }
359
+ const res = await this.client.request("/v1/text-to-music", {
360
+ method: "POST",
361
+ body: form
362
+ });
363
+ return await res.json();
364
+ }
339
365
  };
340
366
 
341
367
  // src/upload.ts
@@ -418,9 +444,12 @@ var VideoToMusic = class {
418
444
  throw new SoniloError("Provide exactly one of video or videoUrl");
419
445
  }
420
446
  let mode = params.mode;
421
- if (params.isolateVocals && mode === void 0) mode = "async";
422
- if (params.isolateVocals && mode !== "async") {
423
- throw new SoniloError('isolateVocals requires mode: "async"');
447
+ const needsAsync = params.isolateVocals || params.preserveSpeech || params.ducking !== void 0 || params.outputFormat === "wav";
448
+ if (mode === void 0) mode = "async";
449
+ if (needsAsync && mode !== "async") {
450
+ throw new SoniloError(
451
+ 'isolateVocals/preserveSpeech/ducking/outputFormat "wav" require mode: "async"'
452
+ );
424
453
  }
425
454
  const form = new FormData();
426
455
  if (params.video !== void 0) {
@@ -433,10 +462,19 @@ var VideoToMusic = class {
433
462
  if (params.segments !== void 0) {
434
463
  form.set("segments", JSON.stringify(params.segments));
435
464
  }
436
- if (mode !== void 0) form.set("mode", mode);
465
+ form.set("mode", mode);
466
+ if (params.preserveSpeech !== void 0) {
467
+ form.set("preserve_speech", String(params.preserveSpeech));
468
+ }
437
469
  if (params.isolateVocals !== void 0) {
438
470
  form.set("isolate_vocals", String(params.isolateVocals));
439
471
  }
472
+ if (params.outputFormat !== void 0) {
473
+ form.set("output_format", params.outputFormat);
474
+ }
475
+ if (params.ducking !== void 0) {
476
+ form.set("ducking", String(params.ducking));
477
+ }
440
478
  const res = await this.client.request("/v1/video-to-music", {
441
479
  method: "POST",
442
480
  body: form
@@ -500,8 +538,135 @@ var VideoToSfx = class {
500
538
  }
501
539
  };
502
540
 
541
+ // src/resources/videoToVideoMusic.ts
542
+ var VideoToVideoMusic = class {
543
+ constructor(client) {
544
+ this.client = client;
545
+ }
546
+ async submit(params) {
547
+ if (params.video === void 0 === (params.videoUrl === void 0)) {
548
+ throw new SoniloError("Provide exactly one of video or videoUrl");
549
+ }
550
+ const form = new FormData();
551
+ if (params.video !== void 0) {
552
+ const { blob, filename } = await toUploadBlob(params.video);
553
+ form.set("video", blob, filename);
554
+ } else {
555
+ form.set("video_url", params.videoUrl);
556
+ }
557
+ if (params.prompt !== void 0) form.set("prompt", params.prompt);
558
+ if (params.preserveSpeech !== void 0) {
559
+ form.set("preserve_speech", String(params.preserveSpeech));
560
+ }
561
+ if (params.isolateVocals !== void 0) {
562
+ form.set("isolate_vocals", String(params.isolateVocals));
563
+ }
564
+ const res = await this.client.request("/v1/video-to-video-music", {
565
+ method: "POST",
566
+ body: form
567
+ });
568
+ return await res.json();
569
+ }
570
+ async generate(params, opts) {
571
+ const task = await this.submit(params);
572
+ return this.client.tasks.wait(task.task_id, opts);
573
+ }
574
+ };
575
+
576
+ // src/resources/videoToVideoSfx.ts
577
+ var VideoToVideoSfx = class {
578
+ constructor(client) {
579
+ this.client = client;
580
+ }
581
+ async submit(params) {
582
+ if (params.video === void 0 === (params.videoUrl === void 0)) {
583
+ throw new SoniloError("Provide exactly one of video or videoUrl");
584
+ }
585
+ const form = new FormData();
586
+ if (params.video !== void 0) {
587
+ const { blob, filename } = await toUploadBlob(params.video);
588
+ form.set("video", blob, filename);
589
+ } else {
590
+ form.set("video_url", params.videoUrl);
591
+ }
592
+ if (params.prompt !== void 0) form.set("prompt", params.prompt);
593
+ if (params.segments !== void 0) {
594
+ form.set("segments", JSON.stringify(params.segments));
595
+ }
596
+ const res = await this.client.request("/v1/video-to-video-sfx", {
597
+ method: "POST",
598
+ body: form
599
+ });
600
+ return await res.json();
601
+ }
602
+ async generate(params, opts) {
603
+ const task = await this.submit(params);
604
+ return this.client.tasks.wait(task.task_id, opts);
605
+ }
606
+ };
607
+
608
+ // src/resources/soundForm.ts
609
+ async function buildSoundForm(params) {
610
+ if (params.video === void 0 === (params.videoUrl === void 0)) {
611
+ throw new SoniloError("Provide exactly one of video or videoUrl");
612
+ }
613
+ const form = new FormData();
614
+ if (params.video !== void 0) {
615
+ const { blob, filename } = await toUploadBlob(params.video);
616
+ form.set("video", blob, filename);
617
+ } else {
618
+ form.set("video_url", params.videoUrl);
619
+ }
620
+ if (params.musicPrompt !== void 0) form.set("music_prompt", params.musicPrompt);
621
+ if (params.sfxPrompt !== void 0) form.set("sfx_prompt", params.sfxPrompt);
622
+ if (params.segments !== void 0) {
623
+ form.set("segments", JSON.stringify(params.segments));
624
+ }
625
+ if (params.preserveSpeech !== void 0) {
626
+ form.set("preserve_speech", String(params.preserveSpeech));
627
+ }
628
+ if (params.ducking !== void 0) form.set("ducking", String(params.ducking));
629
+ return form;
630
+ }
631
+
632
+ // src/resources/videoToSound.ts
633
+ var VideoToSound = class {
634
+ constructor(client) {
635
+ this.client = client;
636
+ }
637
+ async submit(params) {
638
+ const res = await this.client.request("/v1/video-to-sound", {
639
+ method: "POST",
640
+ body: await buildSoundForm(params)
641
+ });
642
+ return await res.json();
643
+ }
644
+ async generate(params, opts) {
645
+ const task = await this.submit(params);
646
+ return this.client.tasks.wait(task.task_id, opts);
647
+ }
648
+ };
649
+
650
+ // src/resources/videoToVideoSound.ts
651
+ var VideoToVideoSound = class {
652
+ constructor(client) {
653
+ this.client = client;
654
+ }
655
+ async submit(params) {
656
+ const res = await this.client.request("/v1/video-to-video-sound", {
657
+ method: "POST",
658
+ body: await buildSoundForm(params)
659
+ });
660
+ return await res.json();
661
+ }
662
+ async generate(params, opts) {
663
+ const task = await this.submit(params);
664
+ return this.client.tasks.wait(task.task_id, opts);
665
+ }
666
+ };
667
+
503
668
  // src/version.ts
504
- var VERSION = "0.2.0";
669
+ var VERSION = "0.4.0";
505
670
 
506
671
  // src/client.ts
507
672
  var DEFAULT_BASE_URL = "https://api.sonilo.com";
@@ -525,6 +690,10 @@ var SoniloClient = class {
525
690
  this.videoToMusic = new VideoToMusic(this);
526
691
  this.textToSfx = new TextToSfx(this);
527
692
  this.videoToSfx = new VideoToSfx(this);
693
+ this.videoToVideoMusic = new VideoToVideoMusic(this);
694
+ this.videoToVideoSfx = new VideoToVideoSfx(this);
695
+ this.videoToSound = new VideoToSound(this);
696
+ this.videoToVideoSound = new VideoToVideoSound(this);
528
697
  }
529
698
  /**
530
699
  * Perform an authenticated request; throws a typed error on non-2xx.
@@ -557,15 +726,16 @@ var SoniloClient = class {
557
726
 
558
727
  // src/download.ts
559
728
  async function download(media, fetchFn = globalThis.fetch, timeout = DEFAULT_TIMEOUT_MS) {
560
- if (!media?.url) {
729
+ const url = typeof media === "string" ? media : media?.url;
730
+ if (!url) {
561
731
  throw new SoniloError("No media to download");
562
732
  }
563
733
  let res;
564
734
  try {
565
- res = await fetchFn(media.url, { signal: AbortSignal.timeout(timeout) });
735
+ res = await fetchFn(url, { signal: AbortSignal.timeout(timeout) });
566
736
  } catch (err) {
567
737
  if (isTimeoutSignalError(err)) {
568
- throw new RequestTimeoutError(`Download of ${media.url} timed out after ${timeout}ms`);
738
+ throw new RequestTimeoutError(`Download of ${url} timed out after ${timeout}ms`);
569
739
  }
570
740
  throw err;
571
741
  }