Skip to content

Commit 1e7c858

Browse files
committed
feat: support Float32Array PCM input conversion #33
1 parent 8557140 commit 1e7c858

File tree

5 files changed

+532
-8
lines changed

5 files changed

+532
-8
lines changed

AI_AGENT_INSTRUCTION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,5 @@ You operate as a senior Node.js and TypeScript backend engineer focused on maint
122122
- When modifying CLI behaviour, test on at least one supported platform or enhance integration tests with synthetic binaries.
123123
- Prefer extending existing classes and helpers over introducing new modules; if a new module is required, place it under `src/core` (runtime logic) or `src/internal` (supporting utilities).
124124
- Document behavioural changes in commit messages following Conventional Commit rules; the release tooling will derive `CHANGELOG.md` automatically.
125+
- Never edit `CHANGELOG.md` by hand; rely on the release workflow or explicit project tooling to populate it.
126+
- After finishing any feature implementation, include in your final response a Conventional Commit-style message suggestion that downstream tooling can use.

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,28 @@ await encoder.encode();
113113
const buffer = encoder.getBuffer();
114114
```
115115

116+
### Encode from Float32Array PCM input
117+
118+
Raw PCM floats from web audio streams can be consumed directly without first converting them to integers.
119+
120+
```js
121+
import { Lame } from "node-lame";
122+
123+
const samples = new Float32Array([-1, -0.5, 0, 0.5, 1]);
124+
125+
const encoder = new Lame({
126+
output: "buffer",
127+
raw: true,
128+
});
129+
130+
encoder.setBuffer(samples);
131+
await encoder.encode();
132+
133+
const buffer = encoder.getBuffer();
134+
```
135+
136+
`setBuffer` also accepts `Float64Array`, `Int16Array`, `Int32Array`, and other `ArrayBufferView` inputs by converting them to the expected PCM encoding under the hood.
137+
116138
### Get status of encoder as object
117139

118140
```js

src/core/lame.ts

Lines changed: 241 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
66
import { dirname, join } from "node:path";
77
import { tmpdir } from "node:os";
88

9-
import type { LameOptionsBag, LameProgressEmitter, LameStatus } from "../types";
9+
import type {
10+
BitWidth,
11+
LameOptionsBag,
12+
LameProgressEmitter,
13+
LameStatus,
14+
} from "../types";
1015
import { resolveLameBinary } from "../internal/binary/resolve-binary";
1116
import { LameOptions } from "./lame-options";
1217

1318
type ProgressKind = "encode" | "decode";
1419

20+
function isFloatArray(
21+
view: ArrayBufferView,
22+
): view is Float32Array | Float64Array {
23+
return view instanceof Float32Array || view instanceof Float64Array;
24+
}
25+
1526
function parseEncodeProgressLine(content: string): {
1627
progress?: number;
1728
eta?: string;
@@ -105,12 +116,12 @@ class Lame {
105116
return this;
106117
}
107118

108-
public setBuffer(file: Buffer): this {
109-
if (!Buffer.isBuffer(file)) {
110-
throw new Error("Audio file (buffer) does not exist");
111-
}
119+
public setBuffer(
120+
file: Buffer | ArrayBuffer | ArrayBufferView,
121+
): this {
122+
const normalized = this.normalizeInputBuffer(file);
112123

113-
this.fileBuffer = file;
124+
this.fileBuffer = normalized;
114125
this.filePath = undefined;
115126

116127
return this;
@@ -362,16 +373,238 @@ class Lame {
362373
});
363374
}
364375

376+
private normalizeInputBuffer(
377+
input: Buffer | ArrayBuffer | ArrayBufferView,
378+
): Buffer {
379+
if (Buffer.isBuffer(input)) {
380+
return input;
381+
}
382+
383+
if (input instanceof ArrayBuffer) {
384+
return Buffer.from(new Uint8Array(input));
385+
}
386+
387+
if (ArrayBuffer.isView(input)) {
388+
return this.convertArrayViewToBuffer(input);
389+
}
390+
391+
throw new Error("Audio file (buffer) does not exist");
392+
}
393+
394+
private convertArrayViewToBuffer(view: ArrayBufferView): Buffer {
395+
if (isFloatArray(view)) {
396+
return this.convertFloatArrayToBuffer(view);
397+
}
398+
399+
const uintView = new Uint8Array(
400+
view.buffer,
401+
view.byteOffset,
402+
view.byteLength,
403+
);
404+
405+
return Buffer.from(uintView);
406+
}
407+
408+
private convertFloatArrayToBuffer(
409+
view: Float32Array | Float64Array,
410+
): Buffer {
411+
const bitwidth: BitWidth = this.options.bitwidth ?? 16;
412+
const useBigEndian = this.shouldUseBigEndian();
413+
const isSigned = this.isSignedForBitwidth(bitwidth);
414+
415+
switch (bitwidth) {
416+
case 8: {
417+
const buffer = Buffer.alloc(view.length);
418+
419+
for (let index = 0; index < view.length; index += 1) {
420+
const sample = view[index];
421+
422+
if (isSigned) {
423+
const value = this.scaleToSignedInteger(sample, 8);
424+
buffer.writeInt8(value, index);
425+
} else {
426+
const value = this.scaleToUnsignedInteger(sample, 8);
427+
buffer.writeUInt8(value, index);
428+
}
429+
}
430+
431+
return buffer;
432+
}
433+
434+
case 16: {
435+
if (!isSigned) {
436+
throw new Error(
437+
"lame: Float PCM input only supports signed samples for bitwidth 16",
438+
);
439+
}
440+
441+
const buffer = Buffer.alloc(view.length * 2);
442+
for (let index = 0; index < view.length; index += 1) {
443+
const value = this.scaleToSignedInteger(view[index], 16);
444+
const offset = index * 2;
445+
446+
if (useBigEndian) {
447+
buffer.writeInt16BE(value, offset);
448+
} else {
449+
buffer.writeInt16LE(value, offset);
450+
}
451+
}
452+
453+
return buffer;
454+
}
455+
456+
case 24: {
457+
if (!isSigned) {
458+
throw new Error(
459+
"lame: Float PCM input only supports signed samples for bitwidth 24",
460+
);
461+
}
462+
463+
const buffer = Buffer.alloc(view.length * 3);
464+
for (let index = 0; index < view.length; index += 1) {
465+
const offset = index * 3;
466+
const scaled = this.scaleToSignedInteger(view[index], 24);
467+
let value = scaled;
468+
469+
if (value < 0) {
470+
value += 1 << 24;
471+
}
472+
473+
if (useBigEndian) {
474+
buffer[offset] = (value >> 16) & 0xff;
475+
buffer[offset + 1] = (value >> 8) & 0xff;
476+
buffer[offset + 2] = value & 0xff;
477+
} else {
478+
buffer[offset] = value & 0xff;
479+
buffer[offset + 1] = (value >> 8) & 0xff;
480+
buffer[offset + 2] = (value >> 16) & 0xff;
481+
}
482+
}
483+
484+
return buffer;
485+
}
486+
487+
case 32: {
488+
if (!isSigned) {
489+
throw new Error(
490+
"lame: Float PCM input only supports signed samples for bitwidth 32",
491+
);
492+
}
493+
494+
const buffer = Buffer.alloc(view.length * 4);
495+
for (let index = 0; index < view.length; index += 1) {
496+
const value = this.scaleToSignedInteger(view[index], 32);
497+
const offset = index * 4;
498+
499+
if (useBigEndian) {
500+
buffer.writeInt32BE(value, offset);
501+
} else {
502+
buffer.writeInt32LE(value, offset);
503+
}
504+
}
505+
506+
return buffer;
507+
}
508+
}
509+
}
510+
511+
private shouldUseBigEndian(): boolean {
512+
if (this.options["big-endian"] === true) {
513+
return true;
514+
}
515+
516+
if (this.options["little-endian"] === true) {
517+
return false;
518+
}
519+
520+
return false;
521+
}
522+
523+
private isSignedForBitwidth(bitwidth: BitWidth): boolean {
524+
if (bitwidth === 8) {
525+
if (this.options.unsigned === true) {
526+
return false;
527+
}
528+
529+
return this.options.signed === true;
530+
}
531+
532+
if (this.options.unsigned === true) {
533+
return false;
534+
}
535+
536+
return true;
537+
}
538+
539+
private clampSample(value: number): number {
540+
if (!Number.isFinite(value)) {
541+
return 0;
542+
}
543+
544+
if (value <= -1) {
545+
return -1;
546+
}
547+
548+
if (value >= 1) {
549+
return 1;
550+
}
551+
552+
return value;
553+
}
554+
555+
private scaleToSignedInteger(value: number, bitwidth: BitWidth): number {
556+
const clamped = this.clampSample(value);
557+
558+
const positiveMax = Math.pow(2, bitwidth - 1) - 1;
559+
const negativeScale = Math.pow(2, bitwidth - 1);
560+
561+
if (clamped <= -1) {
562+
return -negativeScale;
563+
}
564+
565+
if (clamped >= 1) {
566+
return positiveMax;
567+
}
568+
569+
if (clamped < 0) {
570+
const scaled = Math.round(clamped * negativeScale);
571+
return Math.max(-negativeScale, scaled);
572+
}
573+
574+
const scaled = Math.round(clamped * positiveMax);
575+
return Math.min(positiveMax, scaled);
576+
}
577+
578+
private scaleToUnsignedInteger(value: number, bitwidth: BitWidth): number {
579+
const clamped = this.clampSample(value);
580+
const normalized = (clamped + 1) / 2;
581+
const max = Math.pow(2, bitwidth) - 1;
582+
const scaled = Math.round(normalized * max);
583+
584+
return Math.min(Math.max(scaled, 0), max);
585+
}
586+
365587
private async persistInputBufferToTempFile(
366588
type: ProgressKind,
367589
): Promise<string> {
368590
const tempPath = await this.generateTempFilePath("raw", type);
369-
const inputView = Uint8Array.from(this.fileBuffer!);
370-
await writeFile(tempPath, inputView);
591+
await writeFile(tempPath, this.toUint8Array(this.fileBuffer!));
371592
this.fileBufferTempFilePath = tempPath;
372593
return tempPath;
373594
}
374595

596+
private toUint8Array(view: Buffer | ArrayBufferView): Uint8Array {
597+
if (view instanceof Buffer) {
598+
return new Uint8Array(
599+
view.buffer,
600+
view.byteOffset,
601+
view.byteLength,
602+
);
603+
}
604+
605+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
606+
}
607+
375608
private async prepareOutputTarget(type: ProgressKind): Promise<{
376609
outputPath: string;
377610
bufferOutput: boolean;

tests/integration/lame.integration.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,35 @@ process.exit(${exitCode});
106106
expect(encoder.getStatus().finished).toBe(true);
107107
});
108108

109+
it("encodes Float32Array inputs by normalizing to PCM", async () => {
110+
const workdir = await createWorkdir();
111+
const logPath = join(workdir, "encode-float-log.json");
112+
process.env.LAME_TEST_LOG = logPath;
113+
114+
const fakeBinaryPath = await createPassthroughBinary();
115+
const encoder = new Lame({
116+
output: "buffer",
117+
raw: true,
118+
bitrate: 128,
119+
});
120+
121+
const samples = new Float32Array([-1, 0, 1]);
122+
encoder.setBuffer(samples);
123+
encoder.setLamePath(fakeBinaryPath);
124+
125+
await encoder.encode();
126+
127+
const argv = await readLoggedArgs(logPath);
128+
expect(argv).toEqual(expect.arrayContaining(["-r"]));
129+
130+
const output = encoder.getBuffer();
131+
const expected = Buffer.alloc(6);
132+
expected.writeInt16LE(-32768, 0);
133+
expected.writeInt16LE(0, 2);
134+
expected.writeInt16LE(32767, 4);
135+
expect(Buffer.compare(output, expected)).toBe(0);
136+
});
137+
109138
it("encodes files to disk with constant bitrate", async () => {
110139
const workdir = await createWorkdir();
111140
const logPath = join(workdir, "encode-file-log.json");

0 commit comments

Comments
 (0)