ngxson HF staff commited on
Commit
a2a351d
·
1 Parent(s): f09ba53

add blogmode

Browse files
front/package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "front",
9
  "version": "0.0.0",
10
  "dependencies": {
 
11
  "@gradio/client": "^1.12.0",
12
  "@huggingface/hub": "^1.0.1",
13
  "@huggingface/inference": "^3.3.4",
@@ -348,6 +349,12 @@
348
  "node": ">=6.9.0"
349
  }
350
  },
 
 
 
 
 
 
351
  "node_modules/@bufbuild/protobuf": {
352
  "version": "2.2.3",
353
  "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.3.tgz",
 
8
  "name": "front",
9
  "version": "0.0.0",
10
  "dependencies": {
11
+ "@breezystack/lamejs": "^1.2.7",
12
  "@gradio/client": "^1.12.0",
13
  "@huggingface/hub": "^1.0.1",
14
  "@huggingface/inference": "^3.3.4",
 
349
  "node": ">=6.9.0"
350
  }
351
  },
352
+ "node_modules/@breezystack/lamejs": {
353
+ "version": "1.2.7",
354
+ "resolved": "https://registry.npmjs.org/@breezystack/lamejs/-/lamejs-1.2.7.tgz",
355
+ "integrity": "sha512-6wc7ck65ctA75Hq7FYHTtTvGnYs6msgdxiSUICQ+A01nVOWg6rqouZB8IdyteRlfpYYiFovkf67dIeOgWIUzTA==",
356
+ "license": "LGPL-3.0"
357
+ },
358
  "node_modules/@bufbuild/protobuf": {
359
  "version": "2.2.3",
360
  "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.3.tgz",
front/package.json CHANGED
@@ -11,6 +11,7 @@
11
  "preview": "vite preview"
12
  },
13
  "dependencies": {
 
14
  "@gradio/client": "^1.12.0",
15
  "@huggingface/hub": "^1.0.1",
16
  "@huggingface/inference": "^3.3.4",
 
11
  "preview": "vite preview"
12
  },
13
  "dependencies": {
14
+ "@breezystack/lamejs": "^1.2.7",
15
  "@gradio/client": "^1.12.0",
16
  "@huggingface/hub": "^1.0.1",
17
  "@huggingface/inference": "^3.3.4",
front/src/App.tsx CHANGED
@@ -9,6 +9,8 @@ function App() {
9
  const [genratedScript, setGeneratedScript] = useState<string>('');
10
  const [busy, setBusy] = useState<boolean>(false);
11
 
 
 
12
  return (
13
  <div className="bg-base-300 min-h-screen">
14
  <div className="max-w-screen-lg mx-auto p-4 pb-32 grid gap-4 grid-cols-1">
@@ -35,6 +37,7 @@ function App() {
35
  <ScriptMaker
36
  setScript={setGeneratedScript}
37
  setBusy={setBusy}
 
38
  busy={busy}
39
  hfToken={hfToken}
40
  />
@@ -43,6 +46,7 @@ function App() {
43
  genratedScript={genratedScript}
44
  setBusy={setBusy}
45
  busy={busy}
 
46
  />
47
  </>
48
  )}
 
9
  const [genratedScript, setGeneratedScript] = useState<string>('');
10
  const [busy, setBusy] = useState<boolean>(false);
11
 
12
+ const [blogURL, setBlogURL] = useState<string>('');
13
+
14
  return (
15
  <div className="bg-base-300 min-h-screen">
16
  <div className="max-w-screen-lg mx-auto p-4 pb-32 grid gap-4 grid-cols-1">
 
37
  <ScriptMaker
38
  setScript={setGeneratedScript}
39
  setBusy={setBusy}
40
+ setBlogURL={setBlogURL}
41
  busy={busy}
42
  hfToken={hfToken}
43
  />
 
46
  genratedScript={genratedScript}
47
  setBusy={setBusy}
48
  busy={busy}
49
+ blogURL={blogURL}
50
  />
51
  </>
52
  )}
front/src/components/PodcastGenerator.tsx CHANGED
@@ -5,14 +5,18 @@ import { parse } from 'yaml';
5
  import {
6
  addNoise,
7
  addSilence,
 
8
  generateAudio,
 
9
  joinAudio,
10
  loadWavAndDecode,
11
  pickRand,
 
12
  } from '../utils/utils';
13
 
14
  // taken from https://freesound.org/people/artxmp1/sounds/660540
15
  import openingSoundSrc from '../opening-sound.wav';
 
16
 
17
  interface GenerationStep {
18
  turn: PodcastTurn;
@@ -87,9 +91,11 @@ const parseYAML = (yaml: string): Podcast => {
87
  export const PodcastGenerator = ({
88
  genratedScript,
89
  setBusy,
 
90
  busy,
91
  }: {
92
  genratedScript: string;
 
93
  setBusy: (busy: boolean) => void;
94
  busy: boolean;
95
  }) => {
@@ -103,6 +109,14 @@ export const PodcastGenerator = ({
103
  const [speed, setSpeed] = useState<string>('1.2');
104
  const [addIntroMusic, setAddIntroMusic] = useState<boolean>(false);
105
 
 
 
 
 
 
 
 
 
106
  const setRandSpeaker = () => {
107
  const { s1, s2 } = getRandomSpeakerPair();
108
  setSpeaker1(s1);
@@ -117,6 +131,13 @@ export const PodcastGenerator = ({
117
  const generatePodcast = async () => {
118
  setWav(null);
119
  setBusy(true);
 
 
 
 
 
 
 
120
  try {
121
  const podcast = parseYAML(script);
122
  const { speakerNames, turns } = podcast;
@@ -133,7 +154,6 @@ export const PodcastGenerator = ({
133
  const steps: GenerationStep[] = turns.map((turn) => ({ turn }));
134
  setNumSteps(steps.length);
135
  setNumStepsDone(0);
136
- let outputWav: AudioBuffer;
137
  for (let i = 0; i < steps.length; i++) {
138
  const step = steps[i];
139
  const speakerIdx = speakerNames.indexOf(
@@ -174,6 +194,21 @@ export const PodcastGenerator = ({
174
  setBusy(false);
175
  setNumStepsDone(0);
176
  setNumSteps(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  };
178
 
179
  const isGenerating = numSteps > 0;
@@ -183,6 +218,19 @@ export const PodcastGenerator = ({
183
  <div className="card bg-base-100 w-full shadow-xl">
184
  <div className="card-body">
185
  <h2 className="card-title">Step 2: Script (YAML format)</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  <textarea
187
  className="textarea textarea-bordered w-full h-72 p-2"
188
  placeholder="Type your script here..."
@@ -256,6 +304,7 @@ export const PodcastGenerator = ({
256
  </div>
257
 
258
  <button
 
259
  className="btn btn-primary mt-2"
260
  onClick={generatePodcast}
261
  disabled={busy || !script || isGenerating}
@@ -285,9 +334,44 @@ export const PodcastGenerator = ({
285
  <div className="card-body">
286
  <h2 className="card-title">Step 3: Listen to your podcast</h2>
287
  <AudioPlayer audioBuffer={wav} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  </div>
289
  </div>
290
  )}
291
  </>
292
  );
293
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import {
6
  addNoise,
7
  addSilence,
8
+ audioBufferToMp3,
9
  generateAudio,
10
+ isBlogMode,
11
  joinAudio,
12
  loadWavAndDecode,
13
  pickRand,
14
+ uploadFileToHub,
15
  } from '../utils/utils';
16
 
17
  // taken from https://freesound.org/people/artxmp1/sounds/660540
18
  import openingSoundSrc from '../opening-sound.wav';
19
+ import { getBlogComment } from '../utils/prompts';
20
 
21
  interface GenerationStep {
22
  turn: PodcastTurn;
 
91
  export const PodcastGenerator = ({
92
  genratedScript,
93
  setBusy,
94
+ blogURL,
95
  busy,
96
  }: {
97
  genratedScript: string;
98
+ blogURL: string;
99
  setBusy: (busy: boolean) => void;
100
  busy: boolean;
101
  }) => {
 
109
  const [speed, setSpeed] = useState<string>('1.2');
110
  const [addIntroMusic, setAddIntroMusic] = useState<boolean>(false);
111
 
112
+ const [blogFilePushToken, setBlogFilePushToken] = useState<string>(
113
+ localStorage.getItem('blogFilePushToken') || ''
114
+ );
115
+ const [blogCmtOutput, setBlogCmtOutput] = useState<string>('');
116
+ useEffect(() => {
117
+ localStorage.setItem('blogFilePushToken', blogFilePushToken);
118
+ }, [blogFilePushToken]);
119
+
120
  const setRandSpeaker = () => {
121
  const { s1, s2 } = getRandomSpeakerPair();
122
  setSpeaker1(s1);
 
131
  const generatePodcast = async () => {
132
  setWav(null);
133
  setBusy(true);
134
+ setBlogCmtOutput('');
135
+ if (isBlogMode && !blogURL) {
136
+ alert('Please enter a blog slug');
137
+ setBusy(false);
138
+ return;
139
+ }
140
+ let outputWav: AudioBuffer;
141
  try {
142
  const podcast = parseYAML(script);
143
  const { speakerNames, turns } = podcast;
 
154
  const steps: GenerationStep[] = turns.map((turn) => ({ turn }));
155
  setNumSteps(steps.length);
156
  setNumStepsDone(0);
 
157
  for (let i = 0; i < steps.length; i++) {
158
  const step = steps[i];
159
  const speakerIdx = speakerNames.indexOf(
 
194
  setBusy(false);
195
  setNumStepsDone(0);
196
  setNumSteps(0);
197
+
198
+ // maybe upload
199
+ if (isBlogMode && outputWav!) {
200
+ const repoId = 'ngxson/hf-blog-podcast';
201
+ const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
202
+ const filename = `${blogSlug}.mp3`;
203
+ setBlogCmtOutput(`Uploading '${filename}' ...`);
204
+ await uploadFileToHub(
205
+ audioBufferToMp3(outputWav),
206
+ filename,
207
+ repoId,
208
+ blogFilePushToken
209
+ );
210
+ setBlogCmtOutput(getBlogComment(filename));
211
+ }
212
  };
213
 
214
  const isGenerating = numSteps > 0;
 
218
  <div className="card bg-base-100 w-full shadow-xl">
219
  <div className="card-body">
220
  <h2 className="card-title">Step 2: Script (YAML format)</h2>
221
+
222
+ {isBlogMode && (
223
+ <>
224
+ <input
225
+ type="password"
226
+ placeholder="Repo push HF_TOKEN"
227
+ className="input input-bordered w-full"
228
+ value={blogFilePushToken}
229
+ onChange={(e) => setBlogFilePushToken(e.target.value)}
230
+ />
231
+ </>
232
+ )}
233
+
234
  <textarea
235
  className="textarea textarea-bordered w-full h-72 p-2"
236
  placeholder="Type your script here..."
 
304
  </div>
305
 
306
  <button
307
+ id="btn-generate-podcast"
308
  className="btn btn-primary mt-2"
309
  onClick={generatePodcast}
310
  disabled={busy || !script || isGenerating}
 
334
  <div className="card-body">
335
  <h2 className="card-title">Step 3: Listen to your podcast</h2>
336
  <AudioPlayer audioBuffer={wav} />
337
+
338
+ {isBlogMode && (
339
+ <div>
340
+ -------------------
341
+ <br />
342
+ <h2>Comment to be posted:</h2>
343
+ <pre className="p-2 bg-base-200 rounded-md my-2 whitespace-pre-wrap break-words">
344
+ {blogCmtOutput}
345
+ </pre>
346
+ <button
347
+ className="btn btn-sm btn-secondary"
348
+ onClick={() => copyStr(blogCmtOutput)}
349
+ >
350
+ Copy comment
351
+ </button>
352
+ </div>
353
+ )}
354
  </div>
355
  </div>
356
  )}
357
  </>
358
  );
359
  };
360
+
361
+ // copy text to clipboard
362
+ export const copyStr = (textToCopy: string) => {
363
+ // Navigator clipboard api needs a secure context (https)
364
+ if (navigator.clipboard && window.isSecureContext) {
365
+ navigator.clipboard.writeText(textToCopy);
366
+ } else {
367
+ // Use the 'out of viewport hidden text area' trick
368
+ const textArea = document.createElement('textarea');
369
+ textArea.value = textToCopy;
370
+ // Move textarea out of the viewport so it's not visible
371
+ textArea.style.position = 'absolute';
372
+ textArea.style.left = '-999999px';
373
+ document.body.prepend(textArea);
374
+ textArea.select();
375
+ document.execCommand('copy');
376
+ }
377
+ };
front/src/components/ScriptMaker.tsx CHANGED
@@ -1,9 +1,13 @@
1
  import { useEffect, useState } from 'react';
2
  import { CONFIG } from '../config';
3
- import { getPromptGeneratePodcastScript } from '../utils/prompts';
 
 
 
4
  //import { getSSEStreamAsync } from '../utils/utils';
5
  import { EXAMPLES } from '../examples';
6
  import { HfInference } from '@huggingface/inference';
 
7
 
8
  interface SplitContent {
9
  thought: string;
@@ -29,11 +33,13 @@ const splitContent = (content: string): SplitContent => {
29
 
30
  export const ScriptMaker = ({
31
  setScript,
 
32
  setBusy,
33
  busy,
34
  hfToken,
35
  }: {
36
  setScript: (script: string) => void;
 
37
  setBusy: (busy: boolean) => void;
38
  busy: boolean;
39
  hfToken: string;
@@ -45,7 +51,7 @@ export const ScriptMaker = ({
45
  const usingModel = model === 'custom' ? customModel : model;
46
 
47
  const [input, setInput] = useState<string>('');
48
- const [note, setNote] = useState<string>('');
49
  const [thought, setThought] = useState<string>('');
50
  const [isGenerating, setIsGenerating] = useState<boolean>(false);
51
 
@@ -115,6 +121,12 @@ export const ScriptMaker = ({
115
  alert(`ERROR: ${error}`);
116
  }
117
  setIsGenerating(false);
 
 
 
 
 
 
118
  };
119
 
120
  return (
@@ -144,6 +156,17 @@ export const ScriptMaker = ({
144
  ))}
145
  </select>
146
 
 
 
 
 
 
 
 
 
 
 
 
147
  <textarea
148
  className="textarea textarea-bordered w-full h-72 p-2"
149
  placeholder="Type your input information here (an article, a document, etc)..."
 
1
  import { useEffect, useState } from 'react';
2
  import { CONFIG } from '../config';
3
+ import {
4
+ getBlogPrompt,
5
+ getPromptGeneratePodcastScript,
6
+ } from '../utils/prompts';
7
  //import { getSSEStreamAsync } from '../utils/utils';
8
  import { EXAMPLES } from '../examples';
9
  import { HfInference } from '@huggingface/inference';
10
+ import { isBlogMode } from '../utils/utils';
11
 
12
  interface SplitContent {
13
  thought: string;
 
33
 
34
  export const ScriptMaker = ({
35
  setScript,
36
+ setBlogURL,
37
  setBusy,
38
  busy,
39
  hfToken,
40
  }: {
41
  setScript: (script: string) => void;
42
+ setBlogURL: (url: string) => void;
43
  setBusy: (busy: boolean) => void;
44
  busy: boolean;
45
  hfToken: string;
 
51
  const usingModel = model === 'custom' ? customModel : model;
52
 
53
  const [input, setInput] = useState<string>('');
54
+ const [note, setNote] = useState<string>(isBlogMode ? getBlogPrompt() : '');
55
  const [thought, setThought] = useState<string>('');
56
  const [isGenerating, setIsGenerating] = useState<boolean>(false);
57
 
 
121
  alert(`ERROR: ${error}`);
122
  }
123
  setIsGenerating(false);
124
+ setTimeout(() => {
125
+ const generatePodcastBtn = document.getElementById(
126
+ 'btn-generate-podcast'
127
+ );
128
+ generatePodcastBtn?.click();
129
+ }, 50);
130
  };
131
 
132
  return (
 
156
  ))}
157
  </select>
158
 
159
+ {isBlogMode && (
160
+ <>
161
+ <input
162
+ type="text"
163
+ placeholder="Blog URL"
164
+ className="input input-bordered w-full"
165
+ onChange={(e) => setBlogURL(e.target.value)}
166
+ />
167
+ </>
168
+ )}
169
+
170
  <textarea
171
  className="textarea textarea-bordered w-full h-72 p-2"
172
  placeholder="Type your input information here (an article, a document, etc)..."
front/src/utils/prompts.ts CHANGED
@@ -110,3 +110,19 @@ ${note.length < 1 ? '(No note provided)' : note}
110
  Now, think about a detailed plan.
111
 
112
  `.trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  Now, think about a detailed plan.
111
 
112
  `.trim();
113
+
114
+ export const getBlogPrompt = () =>
115
+ `
116
+ The name of podcast series is "Hugging Face Blog"
117
+ Be informative, but keep it engaging, add a little bit of fun, and make it sound like a conversation between two friends.
118
+ `.trim();
119
+
120
+ // not actually a prompt, but a template
121
+ export const getBlogComment = (filename: string) =>
122
+ `
123
+ 📻 🎙️ Hey, I made a podcast about this blog post, check it out!
124
+
125
+ <audio controls src="https://huggingface.co/ngxson/hf-blog-podcast/resolve/main/${filename}"></audio>
126
+
127
+ *This podcast is generated via [ngxson/kokoro-podcast-generator](https://huggingface.co/spaces/ngxson/kokoro-podcast-generator), using DeepSeek-R1 and Kokoro-TTS*
128
+ `.trim();
front/src/utils/utils.ts CHANGED
@@ -1,13 +1,16 @@
1
  // @ts-expect-error this package does not have typing
2
  import TextLineStream from 'textlinestream';
3
  import { Client } from '@gradio/client';
 
4
 
5
  // ponyfill for missing ReadableStream asyncIterator on Safari
6
  import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
7
  import { CONFIG } from '../config';
 
8
 
9
  export const isDev: boolean = import.meta.env.MODE === 'development';
10
  export const testToken: string = import.meta.env.VITE_TEST_TOKEN;
 
11
 
12
  // return URL to the WAV file
13
  export const generateAudio = async (
@@ -15,15 +18,27 @@ export const generateAudio = async (
15
  voice: string,
16
  speed: number = 1.1
17
  ): Promise<string> => {
18
- const client = await Client.connect(CONFIG.ttsSpaceId);
19
- const result = await client.predict('/tts', {
20
- text: content,
21
- voice,
22
- speed,
23
- });
24
-
25
- console.log(result.data);
26
- return (result.data as any)[0].url;
 
 
 
 
 
 
 
 
 
 
 
 
27
  };
28
 
29
  export const pickRand = <T>(arr: T[]): T => {
@@ -49,6 +64,24 @@ export async function* getSSEStreamAsync(fetchResponse: Response) {
49
  }
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  /**
53
  * Ok now, most of the functions below are written by ChatGPT using Reasoning mode.
54
  */
@@ -393,3 +426,84 @@ export const blobFromAudioBuffer = (audioBuffer: AudioBuffer): Blob => {
393
  const wavArrayBuffer = audioBufferToWav(audioBuffer, { float32: false });
394
  return new Blob([wavArrayBuffer], { type: 'audio/wav' });
395
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  // @ts-expect-error this package does not have typing
2
  import TextLineStream from 'textlinestream';
3
  import { Client } from '@gradio/client';
4
+ import * as lamejs from '@breezystack/lamejs';
5
 
6
  // ponyfill for missing ReadableStream asyncIterator on Safari
7
  import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
8
  import { CONFIG } from '../config';
9
+ import { uploadFiles } from '@huggingface/hub';
10
 
11
  export const isDev: boolean = import.meta.env.MODE === 'development';
12
  export const testToken: string = import.meta.env.VITE_TEST_TOKEN;
13
+ export const isBlogMode: boolean = !!window.location.href.match(/blogmode/);
14
 
15
  // return URL to the WAV file
16
  export const generateAudio = async (
 
18
  voice: string,
19
  speed: number = 1.1
20
  ): Promise<string> => {
21
+ const maxRetries = 3;
22
+ for (let i = 0; i < maxRetries; i++) {
23
+ try {
24
+ const client = await Client.connect(CONFIG.ttsSpaceId);
25
+ const result = await client.predict('/tts', {
26
+ text: content,
27
+ voice,
28
+ speed,
29
+ });
30
+
31
+ console.log(result.data);
32
+ return (result.data as any)[0].url;
33
+ } catch (e) {
34
+ if (i === maxRetries - 1) {
35
+ throw e; // last retry, throw error
36
+ }
37
+ console.error('Failed to generate audio, retrying...', e);
38
+ }
39
+ continue;
40
+ }
41
+ return ''; // should never reach here
42
  };
43
 
44
  export const pickRand = <T>(arr: T[]): T => {
 
64
  }
65
  }
66
 
67
+ export const uploadFileToHub = async (
68
+ buf: ArrayBuffer,
69
+ filename: string,
70
+ repoId: string,
71
+ hfToken: string
72
+ ) => {
73
+ await uploadFiles({
74
+ accessToken: hfToken,
75
+ repo: repoId,
76
+ files: [
77
+ {
78
+ path: filename,
79
+ content: new Blob([buf], { type: 'audio/wav' }),
80
+ },
81
+ ],
82
+ });
83
+ };
84
+
85
  /**
86
  * Ok now, most of the functions below are written by ChatGPT using Reasoning mode.
87
  */
 
426
  const wavArrayBuffer = audioBufferToWav(audioBuffer, { float32: false });
427
  return new Blob([wavArrayBuffer], { type: 'audio/wav' });
428
  };
429
+
430
+ export function audioBufferToMp3(buffer: AudioBuffer): ArrayBuffer {
431
+ const numChannels = buffer.numberOfChannels;
432
+ const sampleRate = buffer.sampleRate;
433
+ const bitRate = 128; // kbps - adjust as desired
434
+
435
+ // Initialize MP3 encoder.
436
+ // Note: If more than 2 channels are present, only the first 2 channels will be used.
437
+ const mp3encoder = new lamejs.Mp3Encoder(
438
+ numChannels >= 2 ? 2 : 1,
439
+ sampleRate,
440
+ bitRate
441
+ );
442
+
443
+ const samples = buffer.length;
444
+ const chunkSize = 1152; // Frame size for MP3 encoding
445
+
446
+ // Prepare channel data.
447
+ const channels: Float32Array[] = [];
448
+ for (let ch = 0; ch < numChannels; ch++) {
449
+ channels.push(buffer.getChannelData(ch));
450
+ }
451
+
452
+ const mp3Data: Uint8Array[] = [];
453
+
454
+ // For mono audio, encode directly.
455
+ if (numChannels === 1) {
456
+ for (let i = 0; i < samples; i += chunkSize) {
457
+ const sampleChunk = channels[0].subarray(i, i + chunkSize);
458
+ const int16Buffer = floatTo16BitPCM(sampleChunk);
459
+ const mp3buf = mp3encoder.encodeBuffer(int16Buffer);
460
+ if (mp3buf.length > 0) {
461
+ mp3Data.push(new Uint8Array(mp3buf));
462
+ }
463
+ }
464
+ } else {
465
+ // For stereo (or more channels, use first two channels).
466
+ const left = channels[0];
467
+ const right = channels[1];
468
+ for (let i = 0; i < samples; i += chunkSize) {
469
+ const leftChunk = left.subarray(i, i + chunkSize);
470
+ const rightChunk = right.subarray(i, i + chunkSize);
471
+ const leftInt16 = floatTo16BitPCM(leftChunk);
472
+ const rightInt16 = floatTo16BitPCM(rightChunk);
473
+ const mp3buf = mp3encoder.encodeBuffer(leftInt16, rightInt16);
474
+ if (mp3buf.length > 0) {
475
+ mp3Data.push(new Uint8Array(mp3buf));
476
+ }
477
+ }
478
+ }
479
+
480
+ // Flush the encoder to get any remaining MP3 data.
481
+ const endBuf = mp3encoder.flush();
482
+ if (endBuf.length > 0) {
483
+ mp3Data.push(new Uint8Array(endBuf));
484
+ }
485
+
486
+ // Concatenate all MP3 chunks into a single ArrayBuffer.
487
+ const totalLength = mp3Data.reduce((acc, curr) => acc + curr.length, 0);
488
+ const result = new Uint8Array(totalLength);
489
+ let offset = 0;
490
+ for (const chunk of mp3Data) {
491
+ result.set(chunk, offset);
492
+ offset += chunk.length;
493
+ }
494
+
495
+ return result.buffer;
496
+ }
497
+
498
+ /**
499
+ * Helper function that converts a Float32Array of PCM samples (range -1..1)
500
+ * into an Int16Array (range -32768..32767).
501
+ */
502
+ function floatTo16BitPCM(input: Float32Array): Int16Array {
503
+ const output = new Int16Array(input.length);
504
+ for (let i = 0; i < input.length; i++) {
505
+ const s = Math.max(-1, Math.min(1, input[i]));
506
+ output[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
507
+ }
508
+ return output;
509
+ }
index.html CHANGED
The diff for this file is too large to render. See raw diff