From 63345db3acc2b1c8559e019275d85d3363d990f8 Mon Sep 17 00:00:00 2001 From: D_TWO_TWO <97068888+dtwotwo@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:21:00 +0300 Subject: [PATCH] Add Opus audio format support Add Opus decoding support for HashLink target. - Add OpusData.hx decoder using @:hlNative("fmt", ...) bindings for opus_open, opus_info, opus_read, opus_seek - Update Sound.hx to detect Ogg Opus via "OpusHead" magic bytes after Ogg page header, distinguishing from Ogg Vorbis - Register .opus extension in Config.hx with wav as paired extension, auto-ignored on non-HL platforms --- hxd/res/Config.hx | 4 +- hxd/res/Sound.hx | 42 ++++++++++++++++--- hxd/snd/OpusData.hx | 99 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 hxd/snd/OpusData.hx diff --git a/hxd/res/Config.hx b/hxd/res/Config.hx index e03aa8af8f..54a83fab31 100644 --- a/hxd/res/Config.hx +++ b/hxd/res/Config.hx @@ -21,7 +21,7 @@ class Config { "ttf" => "hxd.res.Font", "fnt" => "hxd.res.BitmapFont", "bdf" => "hxd.res.BDFFont", - "wav,mp3,ogg" => "hxd.res.Sound", + "wav,mp3,ogg,opus" => "hxd.res.Sound", "tmx" => "hxd.res.TiledMap", "atlas" => "hxd.res.Atlas", "grd" => "hxd.res.Gradients", @@ -64,6 +64,7 @@ class Config { "cdb" => "img", "atlas" => "png", "ogg" => "wav", + "opus" => "wav", "mp3" => "wav", "l3d" => "bake", "css" => "less,css.map", @@ -94,6 +95,7 @@ class Config { #if !stb_ogg_sound ignoredExtensions.set("ogg", true); #end + ignoredExtensions.set("opus", true); } return pf; } diff --git a/hxd/res/Sound.hx b/hxd/res/Sound.hx index 53f51fa5e6..8e6fc60d37 100644 --- a/hxd/res/Sound.hx +++ b/hxd/res/Sound.hx @@ -4,6 +4,7 @@ enum SoundFormat { Wav; Mp3; OggVorbis; + Opus; } class Sound extends Resource { @@ -24,6 +25,12 @@ class Sound extends Resource { #else return false; #end + case Opus: + #if hl + return true; + #else + return false; + #end } } @@ -36,12 +43,20 @@ class Sound extends Resource { data = new hxd.snd.WavData(bytes); case 255, 'I'.code: // MP3 (or ID3) data = new hxd.snd.Mp3Data(bytes); - case 'O'.code: // Ogg (vorbis) - #if (hl || stb_ogg_sound) - data = new hxd.snd.OggData(bytes); - #else - throw "OGG format requires -lib stb_ogg_sound (for " + entry.path+")"; - #end + case 'O'.code: // Ogg container (vorbis or opus) + if( isOpusStream(bytes) ) { + #if hl + data = new hxd.snd.OpusData(bytes); + #else + throw "Opus format requires HashLink (for " + entry.path + ")"; + #end + } else { + #if (hl || stb_ogg_sound) + data = new hxd.snd.OggData(bytes); + #else + throw "OGG format requires -lib stb_ogg_sound (for " + entry.path+")"; + #end + } default: } if( data == null ) @@ -51,6 +66,21 @@ class Sound extends Resource { return data; } + static function isOpusStream( bytes : haxe.io.Bytes ) : Bool { + if( bytes.length < 36 ) return false; + var numSegments = bytes.get(26); + var dataOffset = 27 + numSegments; + if( bytes.length < dataOffset + 8 ) return false; + return bytes.get(dataOffset) == 'O'.code + && bytes.get(dataOffset + 1) == 'p'.code + && bytes.get(dataOffset + 2) == 'u'.code + && bytes.get(dataOffset + 3) == 's'.code + && bytes.get(dataOffset + 4) == 'H'.code + && bytes.get(dataOffset + 5) == 'e'.code + && bytes.get(dataOffset + 6) == 'a'.code + && bytes.get(dataOffset + 7) == 'd'.code; + } + public function dispose() { stop(); data = null; diff --git a/hxd/snd/OpusData.hx b/hxd/snd/OpusData.hx new file mode 100644 index 0000000000..92d48fd51a --- /dev/null +++ b/hxd/snd/OpusData.hx @@ -0,0 +1,99 @@ +package hxd.snd; + +#if hl + +private typedef OpusFile = hl.Abstract<"fmt_opus">; + +class OpusData extends Data { + + var bytes : haxe.io.Bytes; + var reader : OpusFile; + var currentSample : Int; + + public function new( bytes : haxe.io.Bytes ) { + this.bytes = bytes; + reader = opus_open(bytes, bytes.length); + if( reader == null ) throw "Failed to decode Opus data"; + + var b = 0, f = 0, s = 0, c = 0; + opus_info(reader, b, f, s, c); + channels = c; + samples = s; + sampleFormat = I16; + samplingRate = f; // Always 48000 for Opus + } + + override function resample(rate:Int, format:Data.SampleFormat, channels:Int):Data { + switch( format ) { + case I16 if( channels == this.channels && rate == this.samplingRate ): + var g = new OpusData(bytes); + return g; + case F32 if( channels == this.channels && rate == this.samplingRate ): + var g = new OpusData(bytes); + g.sampleFormat = F32; + return g; + default: + return super.resample(rate, format, channels); + } + } + + override function decodeBuffer(out:haxe.io.Bytes, outPos:Int, sampleStart:Int, sampleCount:Int) { + if( currentSample != sampleStart ) { + currentSample = sampleStart; + if( !opus_seek(reader, sampleStart) ) throw "Invalid sample start"; + } + var bpp = getBytesPerSample(); + var output : hl.Bytes = out; + output = output.offset(outPos); + var format = switch( sampleFormat ) { + case I16: 2; + case F32: 3; + default: + throw "assert"; + } + var bytesNeeded = sampleCount * bpp; + while( bytesNeeded > 0 ) { + var read = opus_read(reader, output, bytesNeeded, format); + if( read < 0 ) throw "Failed to decode Opus data"; + if( read == 0 ) { + // EOF + output.fill(0, bytesNeeded, 0); + break; + } + bytesNeeded -= read; + output = output.offset(read); + } + currentSample += sampleCount; + } + + @:hlNative("fmt", "opus_open") static function opus_open( bytes : hl.Bytes, size : Int ) : OpusFile { + return null; + } + + @:hlNative("fmt", "opus_seek") static function opus_seek( o : OpusFile, sample : Int ) : Bool { + return false; + } + + @:hlNative("fmt", "opus_info") static function opus_info( o : OpusFile, bitrate : hl.Ref, freq : hl.Ref, samples : hl.Ref, channels : hl.Ref ) : Void { + } + + @:hlNative("fmt", "opus_read") static function opus_read( o : OpusFile, output : hl.Bytes, size : Int, format : Int ) : Int { + return 0; + } + +} + +#else + +class OpusData extends Data { + + public function new( bytes : haxe.io.Bytes ) { + } + + override function decodeBuffer(out:haxe.io.Bytes, outPos:Int, sampleStart:Int, sampleCount:Int) { + throw "Opus support requires HashLink"; + } + +} + +#end