diff --git a/README.md b/README.md index 7ed17dd..1210cf3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ const encoder = new OpusEncoder(48000, 2); const encoded = encoder.encode(pcm); const decoded = encoder.decode(encoded); const decodedFloat = encoder.decodeFloat(encoded); + +// 20 ms of interleaved stereo float32 PCM at 48 kHz. +const floatPcm = Buffer.from(new Float32Array(960 * 2).buffer); +const encodedFloat = encoder.encodeFloat(floatPcm); ``` --- @@ -109,6 +113,44 @@ const packet = encoder.encode(pcm); --- +### `encoder.encodeFloat(pcm: Buffer): Buffer` + +Encode a single frame of 32-bit floating-point PCM into an Opus packet. + +#### Input (encodeFloat) + +- `pcm` – Node `Buffer` containing finite IEEE 754 **float32 PCM** samples: + + - Interleaved by channel (LRLRLR… for stereo). + - Native little-endian byte order on the supported platforms. + - Exactly four bytes per sample and a total byte length divisible by + `channels * 4`. + +- Each call must contain exactly one valid Opus frame: **2.5, 5, 10, 20, 40, + or 60 ms** per channel. The buffer must not be empty. + +The method safely accepts Buffer slices with unaligned byte offsets; it copies +the PCM into aligned native storage before calling libopus. + +#### Output (encodeFloat) + +- Returns a `Buffer` containing a single Opus packet. +- The buffer length is the exact size of the encoded packet. + +For example, encode 20 ms of mono float PCM at 16 kHz: + +```js +const samplesPerFrame = 320; +const pcm = Buffer.alloc(samplesPerFrame * Float32Array.BYTES_PER_ELEMENT); +for (let index = 0; index < samplesPerFrame; index++) { + pcm.writeFloatLE(Math.sin((2 * Math.PI * index) / 40) * 0.5, index * 4); +} + +const packet = encoder.encodeFloat(pcm); +``` + +--- + ### `encoder.decode(packet: Buffer): Buffer` Decode a single Opus packet into PCM. diff --git a/src/index.ts b/src/index.ts index 246f3a5..0986597 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,11 @@ export interface OpusPlcDecodeOptions { export interface OpusEncoder { encode(buf: Buffer): Buffer; + /** + * Encodes interleaved, finite 32-bit floating-point little-endian PCM. + * The frame must be 2.5, 5, 10, 20, 40, or 60 ms long. + */ + encodeFloat(buf: Buffer): Buffer; /** * Decodes the given Opus buffer to PCM signed 16-bit little-endian * @param buf Opus buffer diff --git a/src/node-opus.cc b/src/node-opus.cc index 6cf1cff..94caed1 100644 --- a/src/node-opus.cc +++ b/src/node-opus.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include "../libopus/opus/include/opus.h" // ----------------------------------------------------------------------------- @@ -13,6 +14,7 @@ static constexpr int MAX_FRAME_SIZE = 5760; // 120 ms @ 48 kHz mono static constexpr int MAX_PACKET_SIZE = 1276; // per Opus spec static constexpr int MAX_FRAME_DURATION_MS = 120; +static constexpr int MAX_ENCODE_FRAME_DURATION_MS = 60; static int MaxFrameSizeForRate(opus_int32 rate) { @@ -24,6 +26,26 @@ static int SamplesPer2Point5ms(opus_int32 rate) return rate / 400; } +static bool IsValidEncodeFrameSize(opus_int32 rate, int frameSize) +{ + const int samplesPer2Point5ms = SamplesPer2Point5ms(rate); + if (frameSize < samplesPer2Point5ms || frameSize % samplesPer2Point5ms != 0) + return false; + + switch (frameSize / samplesPer2Point5ms) + { + case 1: // 2.5 ms + case 2: // 5 ms + case 4: // 10 ms + case 8: // 20 ms + case 16: // 40 ms + case 24: // 60 ms + return true; + default: + return false; + } +} + // ----------------------------------------------------------------------------- // Utility: translate libopus error codes to strings // ----------------------------------------------------------------------------- @@ -65,6 +87,7 @@ class OpusEncoderWrap : public Napi::ObjectWrap private: // JS‑exposed methods Napi::Value Encode(const Napi::CallbackInfo &); + Napi::Value EncodeFloat(const Napi::CallbackInfo &); Napi::Value Decode(const Napi::CallbackInfo &); Napi::Value DecodeFloat(const Napi::CallbackInfo &); Napi::Value ApplyEncoderCTL(const Napi::CallbackInfo &); @@ -185,6 +208,79 @@ Napi::Value OpusEncoderWrap::Encode(const Napi::CallbackInfo &info) return Napi::Buffer::Copy(env, reinterpret_cast(outOpus_), clen); } +// ----------------------------------------------------------------------------- +// Encode 32-bit float PCM -> Opus packet (returns Buffer) +// ----------------------------------------------------------------------------- +Napi::Value OpusEncoderWrap::EncodeFloat(const Napi::CallbackInfo &info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsBuffer()) + { + Napi::TypeError::New(env, "Argument must be a Buffer containing 32-bit float PCM").ThrowAsJavaScriptException(); + return env.Null(); + } + + if (EnsureEncoder() != OPUS_OK) + { + Napi::Error::New(env, "Failed to create libopus encoder (bad params?)").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Buffer buf = info[0].As>(); + const size_t bytesPerFrame = sizeof(float) * static_cast(channels_); + if (buf.Length() == 0) + { + Napi::RangeError::New(env, "Float PCM buffer must contain at least one frame").ThrowAsJavaScriptException(); + return env.Null(); + } + if (buf.Length() % bytesPerFrame != 0) + { + Napi::RangeError::New(env, "Float PCM buffer length must be a multiple of (channels*4 bytes)").ThrowAsJavaScriptException(); + return env.Null(); + } + + const size_t frameSize = buf.Length() / bytesPerFrame; + const size_t maxEncodeFrameSize = static_cast(rate_) * MAX_ENCODE_FRAME_DURATION_MS / 1000; + if (frameSize > maxEncodeFrameSize) + { + Napi::RangeError::New(env, "Float PCM frame exceeds the maximum 60 ms Opus encode duration").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!IsValidEncodeFrameSize(rate_, static_cast(frameSize))) + { + Napi::RangeError::New(env, "Float PCM frame size must be one of 2.5, 5, 10, 20, 40, or 60 ms").ThrowAsJavaScriptException(); + return env.Null(); + } + + // Buffer data is byte-aligned, not necessarily float-aligned (for example, + // Buffer#subarray(1)). Copy it before reading floats to avoid undefined + // behavior on architectures that require aligned float access. + std::vector pcm(frameSize * static_cast(channels_)); + std::memcpy(pcm.data(), buf.Data(), buf.Length()); + for (float sample : pcm) + { + if (!std::isfinite(sample)) + { + Napi::RangeError::New(env, "Float PCM samples must be finite").ThrowAsJavaScriptException(); + return env.Null(); + } + } + + int clen = opus_encode_float(enc_, pcm.data(), static_cast(frameSize), outOpus_, MAX_PACKET_SIZE); + if (clen < 0) + { + Napi::Error::New(env, StrError(clen)).ThrowAsJavaScriptException(); + return env.Null(); + } + if (clen == 0) + { + Napi::Error::New(env, "libopus encoder produced an empty packet").ThrowAsJavaScriptException(); + return env.Null(); + } + + return Napi::Buffer::Copy(env, reinterpret_cast(outOpus_), clen); +} + // ----------------------------------------------------------------------------- // Decode Opus packet -> PCM buffer // ----------------------------------------------------------------------------- @@ -436,6 +532,7 @@ Napi::Object OpusEncoderWrap::Init(Napi::Env env, Napi::Object exports) { Napi::Function ctor = Napi::ObjectWrap::DefineClass(env, "OpusEncoder", { InstanceMethod("encode", &OpusEncoderWrap::Encode), + InstanceMethod("encodeFloat", &OpusEncoderWrap::EncodeFloat), InstanceMethod("decode", &OpusEncoderWrap::Decode), InstanceMethod("decodeFloat", &OpusEncoderWrap::DecodeFloat), InstanceMethod("applyEncoderCTL", &OpusEncoderWrap::ApplyEncoderCTL), diff --git a/src/node-opus.h b/src/node-opus.h index 0f8f761..27e77a1 100644 --- a/src/node-opus.h +++ b/src/node-opus.h @@ -13,6 +13,7 @@ class OpusEncoderWrap : public Napi::ObjectWrap // JS-exposed methods Napi::Value Encode(const Napi::CallbackInfo &info); + Napi::Value EncodeFloat(const Napi::CallbackInfo &info); Napi::Value Decode(const Napi::CallbackInfo &info); Napi::Value DecodeFloat(const Napi::CallbackInfo &info); Napi::Value ApplyEncoderCTL(const Napi::CallbackInfo &info); diff --git a/src/tests/test.js b/src/tests/test.js index 14fd7d8..0a9d0d5 100644 --- a/src/tests/test.js +++ b/src/tests/test.js @@ -39,6 +39,45 @@ function assertStereoInterleaving(pcm, message) { } } +function createFloatPcm(samplesPerChannel, channelCount) { + const pcm = Buffer.alloc(samplesPerChannel * channelCount * Float32Array.BYTES_PER_ELEMENT); + for (let frameIndex = 0; frameIndex < samplesPerChannel; frameIndex++) { + for (let channel = 0; channel < channelCount; channel++) { + const period = channel === 0 ? 40 : 57; + const amplitude = channel === 0 ? 0.5 : 0.35; + pcm.writeFloatLE( + amplitude * Math.sin((2 * Math.PI * frameIndex) / period), + (frameIndex * channelCount + channel) * Float32Array.BYTES_PER_ELEMENT, + ); + } + } + return pcm; +} + +function assertFiniteNonSilentFloatPcm(pcm, channelCount, message) { + assert.strictEqual( + pcm.byteLength % Float32Array.BYTES_PER_ELEMENT, + 0, + `${message}: byte length is not float32-aligned`, + ); + + const energyByChannel = Array.from({ length: channelCount }, () => 0); + for ( + let byteOffset = 0; + byteOffset < pcm.byteLength; + byteOffset += Float32Array.BYTES_PER_ELEMENT + ) { + const sample = pcm.readFloatLE(byteOffset); + assert(Number.isFinite(sample), `${message}: contains a non-finite sample`); + energyByChannel[(byteOffset / Float32Array.BYTES_PER_ELEMENT) % channelCount] += + Math.abs(sample); + } + + for (let channel = 0; channel < channelCount; channel++) { + assert(energyByChannel[channel] > 5, `${message}: channel ${channel} is silent`); + } +} + // The one-argument API remains the normal packet decode path. const opus = new OpusEncoder(sampleRate, channels); const decoded = opus.decode(frame); @@ -217,6 +256,124 @@ assert.throws( "decodeFloat should reject non-Buffer values", ); +// Float encoding mirrors float decoding and accepts interleaved IEEE 754 +// float32 PCM. The round trip is lossy, so assert frame size plus finite, +// non-silent output instead of comparing individual samples exactly. +const floatPcm = createFloatPcm(frameSize, channels); +for (const [duration, samplesPerChannel] of [ + [2.5, 40], + [5, 80], + [10, 160], + [20, 320], + [40, 640], + [60, 960], +]) { + const input = createFloatPcm(samplesPerChannel, channels); + const encoder = new OpusEncoder(sampleRate, channels); + const decoder = new OpusEncoder(sampleRate, channels); + + // Opus has algorithmic lookahead. Prime both stateful sides before testing + // the packet so even a 2.5 ms frame contains recovered audio, not startup + // delay silence. + for (let index = 0; index < 4; index++) { + decoder.decodeFloat(encoder.encodeFloat(input)); + } + + const packet = encoder.encodeFloat(input); + assert(packet.length > 0, `Float ${duration} ms frame did not produce an Opus packet`); + const output = decoder.decodeFloat(packet); + assert.strictEqual( + output.length, + input.length, + `Float ${duration} ms round trip returned the wrong frame length`, + ); + assertFiniteNonSilentFloatPcm(output, channels, `Float ${duration} ms round trip`); +} + +const stereoFloatPcm = createFloatPcm(frameSize, stereoChannels); +const stereoFloatEncoder = new OpusEncoder(sampleRate, stereoChannels); +const encodedStereoFloat = stereoFloatEncoder.encodeFloat(stereoFloatPcm); +assert(encodedStereoFloat.length > 0, "Float stereo frame did not produce an Opus packet"); +const stereoFloatRoundTrip = new OpusEncoder(sampleRate, stereoChannels).decodeFloat( + encodedStereoFloat, +); +assert.strictEqual( + stereoFloatRoundTrip.length, + stereoFloatPcm.length, + "Float stereo round trip returned the wrong frame length", +); +assertFiniteNonSilentFloatPcm(stereoFloatRoundTrip, stereoChannels, "Float stereo round trip"); + +// The native implementation must not reinterpret an unaligned Buffer pointer +// as float*. A Buffer view offset by one byte reproduces that case. +const paddedFloatPcm = Buffer.allocUnsafeSlow(floatPcm.length + 1); +floatPcm.copy(paddedFloatPcm, 1); +const unalignedFloatPcm = paddedFloatPcm.subarray(1); +assert.notStrictEqual( + unalignedFloatPcm.byteOffset % Float32Array.BYTES_PER_ELEMENT, + 0, + "Float regression input must be unaligned", +); +const unalignedEncodedFloat = new OpusEncoder(sampleRate, channels).encodeFloat(unalignedFloatPcm); +assert(unalignedEncodedFloat.length > 0, "Unaligned float frame did not produce an Opus packet"); +const unalignedRoundTrip = new OpusEncoder(sampleRate, channels).decodeFloat(unalignedEncodedFloat); +assert.strictEqual( + unalignedRoundTrip.length, + floatPcm.length, + "Unaligned float round trip returned the wrong frame length", +); +assertFiniteNonSilentFloatPcm(unalignedRoundTrip, channels, "Unaligned float round trip"); + +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(null), + /Argument must be a Buffer containing 32-bit float PCM/, + "encodeFloat should reject null", +); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat("not a buffer"), + /Argument must be a Buffer containing 32-bit float PCM/, + "encodeFloat should reject non-Buffer values", +); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(Buffer.alloc(0)), + /must contain at least one frame/, + "encodeFloat should reject an empty buffer", +); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(Buffer.alloc(floatPcm.length - 1)), + /multiple of \(channels\*4 bytes\)/, + "encodeFloat should reject a mono buffer with a partial float sample", +); +assert.throws( + () => new OpusEncoder(sampleRate, stereoChannels).encodeFloat(Buffer.alloc(7)), + /multiple of \(channels\*4 bytes\)/, + "encodeFloat should reject a stereo buffer with a partial frame", +); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(Buffer.alloc(240 * 4)), + /must be one of 2.5, 5, 10, 20, 40, or 60 ms/, + "encodeFloat should reject an unsupported 15 ms frame", +); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(Buffer.alloc((960 + 1) * 4)), + /maximum 60 ms Opus encode duration/, + "encodeFloat should reject a frame longer than 60 ms", +); +const nanFloatPcm = Buffer.from(floatPcm); +nanFloatPcm.writeFloatLE(Number.NaN, 0); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(nanFloatPcm), + /samples must be finite/, + "encodeFloat should reject NaN PCM samples", +); +const infiniteFloatPcm = Buffer.from(floatPcm); +infiniteFloatPcm.writeFloatLE(Number.POSITIVE_INFINITY, 0); +assert.throws( + () => new OpusEncoder(sampleRate, channels).encodeFloat(infiniteFloatPcm), + /samples must be finite/, + "encodeFloat should reject infinite PCM samples", +); + // Exercise the encoder's non-silent path. This reaches the SILK NEON routines // used by arm64 builds, which must all be present in the native addon. const pcm = Buffer.alloc(320 * Int16Array.BYTES_PER_ELEMENT); // 20 ms @ 16 kHz mono @@ -227,4 +384,14 @@ for (let i = 0; i < 320; i++) { const encoder = new OpusEncoder(16_000, 1); const encoded = encoder.encode(pcm); assert(encoded.length > 0, "Non-silent frame did not produce an Opus packet"); +const decodedInt16RoundTrip = new OpusEncoder(16_000, 1).decode(encoded); +assert.strictEqual( + decodedInt16RoundTrip.length, + pcm.length, + "Int16 round trip returned the wrong frame length", +); +assert( + pcmEnergy(decodedInt16RoundTrip) > 10_000, + "Int16 round trip should produce non-silent audio", +); console.log("Passed");