diff --git a/src/node-opus.cc b/src/node-opus.cc index 930cb8bc..2b01c358 100644 --- a/src/node-opus.cc +++ b/src/node-opus.cc @@ -31,6 +31,7 @@ Object NodeOpusEncoder::Init(Napi::Env env, Object exports) { Function func = DefineClass(env, "OpusEncoder", { InstanceMethod("encode", &NodeOpusEncoder::Encode), InstanceMethod("decode", &NodeOpusEncoder::Decode), + InstanceMethod("conceal", &NodeOpusEncoder::Conceal), InstanceMethod("applyEncoderCTL", &NodeOpusEncoder::ApplyEncoderCTL), InstanceMethod("applyDecoderCTL", &NodeOpusEncoder::ApplyDecoderCTL), InstanceMethod("setBitrate", &NodeOpusEncoder::SetBitrate), @@ -177,6 +178,73 @@ Napi::Value NodeOpusEncoder::Decode(const CallbackInfo& args) { return env.Null(); } +Napi::Value NodeOpusEncoder::Conceal(const CallbackInfo& args) { + Napi::Env env = args.Env(); + + if (args.Length() < 1) { + Napi::RangeError::New(env, "Expected at least 1 argument").ThrowAsJavaScriptException(); + return env.Null(); + } + + if (!args[0].IsNumber()) { + Napi::TypeError::New(env, "frame_size parameter must be a number").ThrowAsJavaScriptException(); + return env.Null(); + } + + int frame_size = args[0].ToNumber().Int32Value(); + + // Validate frame_size + if (frame_size <= 0 || frame_size > MAX_FRAME_SIZE) { + Napi::RangeError::New(env, "frame_size must be between 1 and " + std::to_string(MAX_FRAME_SIZE)).ThrowAsJavaScriptException(); + return env.Null(); + } + + // Optional packet parameter for FEC + unsigned char* compressedData = nullptr; + size_t compressedDataLength = 0; + int decode_fec = 0; + + if (args.Length() >= 2 && !args[1].IsNull() && !args[1].IsUndefined()) { + if (!args[1].IsBuffer()) { + Napi::TypeError::New(env, "Packet parameter must be a buffer, null, or undefined").ThrowAsJavaScriptException(); + return env.Null(); + } + + Buffer buf = args[1].As>(); + compressedData = buf.Data(); + compressedDataLength = buf.Length(); + decode_fec = 1; // Enable FEC when packet is provided + } + + if (this->EnsureDecoder() != OPUS_OK) { + Napi::Error::New(env, "Could not create decoder. Check the decoder parameters").ThrowAsJavaScriptException(); + return env.Null(); + } + + int decodedSamples = opus_decode( + this->decoder, + compressedData, + compressedDataLength, + &(this->outPcm[0]), + frame_size, + decode_fec + ); + + if (decodedSamples < 0) { + Napi::TypeError::New(env, getDecodeError(decodedSamples)).ThrowAsJavaScriptException(); + return env.Null(); + } + + int decodedLength = decodedSamples * 2 * this->channels; + + Buffer actualBuf = Buffer::Copy(env, reinterpret_cast(this->outPcm), decodedLength); + + if (!actualBuf.IsEmpty()) return actualBuf; + + Napi::Error::New(env, "Could not decode the data").ThrowAsJavaScriptException(); + return env.Null(); +} + void NodeOpusEncoder::ApplyEncoderCTL(const CallbackInfo& args) { Napi::Env env = args.Env(); diff --git a/src/node-opus.h b/src/node-opus.h index 30b53f41..be05739f 100644 --- a/src/node-opus.h +++ b/src/node-opus.h @@ -32,7 +32,9 @@ class NodeOpusEncoder : public ObjectWrap { Napi::Value Encode(const CallbackInfo& args); Napi::Value Decode(const CallbackInfo& args); - + + Napi::Value Conceal(const CallbackInfo& args); + void ApplyEncoderCTL(const CallbackInfo& args); void ApplyDecoderCTL(const CallbackInfo& args); diff --git a/tests/test.js b/tests/test.js index b28b543a..7024eed5 100644 --- a/tests/test.js +++ b/tests/test.js @@ -31,4 +31,38 @@ const { OpusEncoder } = require('../lib/index.js'); assert.throws(() => new OpusEncoder(16000, null), /Expected channels to be a number/); } +// Packet loss concealment (PLC) with conceal method +{ + const opus = new OpusEncoder(16_000, 1); + + // Conceal with specific frame_size for proper PLC + // For 16kHz, 20ms = 320 samples, so we expect 320 * 2 bytes = 640 bytes output + const plcFrame = opus.conceal(320); + assert(plcFrame.length === 640, `PLC frame with frame_size=320 should be 640 bytes, got ${plcFrame.length}`); + + // Test with 48kHz decoder + const opus48 = new OpusEncoder(48_000, 2); + // For 48kHz stereo, 20ms = 960 samples per channel, output is 960 * 2 channels * 2 bytes = 3840 bytes + const plcFrame48 = opus48.conceal(960); + assert(plcFrame48.length === 3840, `PLC frame for 48kHz stereo with frame_size=960 should be 3840 bytes, got ${plcFrame48.length}`); +} + +// Forward error correction (FEC) with conceal method +{ + const opus = new OpusEncoder(16_000, 1); + const frame = fs.readFileSync(path.join(__dirname, 'frame.opus')); + + // Conceal with FEC using a packet (frame_size=320 for 20ms at 16kHz) + const decoded1 = opus.conceal(320, frame); + assert(decoded1.length === 640, 'FEC decoded frame length is not 640'); + + // Conceal without packet (PLC only) + const decoded2 = opus.conceal(320); + assert(decoded2.length === 640, 'PLC decoded frame length is not 640'); + + // Conceal with null packet (PLC) + const decoded3 = opus.conceal(320, null); + assert(decoded3.length === 640, 'PLC decoded frame length is not 640'); +} + console.log('Passed'); diff --git a/typings/index.d.ts b/typings/index.d.ts index eaa6757d..62dc8b1e 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -7,6 +7,12 @@ declare module '@discordjs/opus' { * @param buf Opus buffer */ public decode(buf: Buffer): Buffer; + /** + * Performs packet loss concealment (PLC) or forward error correction (FEC) + * @param frame_size Number of samples per channel to generate. This must be exactly the duration of the missing audio (e.g., 960 for 20ms at 48kHz, 320 for 20ms at 16kHz). + * @param packet Optional Opus packet buffer for FEC. If provided, FEC will be used to reconstruct the audio. If omitted, null, or undefined, PLC will generate synthetic audio. + */ + public conceal(frame_size: number, packet?: Buffer | null): Buffer; public applyEncoderCTL(ctl: number, value: number): void; public applyDecoderCTL(ctl: number, value: number): void; public setBitrate(bitrate: number): void;