From a3767af14144d768db153b0344c0aa95a3e3a029 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:40:07 +0200 Subject: [PATCH] Fix HLS muxer audio segmentation bug, fix conversion tests failing in CI --- src/hls/hls-muxer.ts | 12 +++++--- src/target.ts | 2 +- test/browser/conversion.test.ts | 50 ++++++++++++++++-------------- test/node/hls-output.test.ts | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 9046704..3b2917f 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -691,6 +691,7 @@ export class HlsMuxer extends Muxer { let audioEndIndex = 0; if (videoTrack && (!videoTrack.closed || videoTrack.packets.length > 0)) { + // A video track is active (and maybe an audio track too) const allBelow = videoTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp); let bestKeyPacket: EncodedPacket | null = null; @@ -773,21 +774,22 @@ export class HlsMuxer extends Muxer { } } } else if (audioTrack && (!audioTrack.closed || audioTrack.packets.length > 0)) { + // There's only an audio track active + const allBelow = audioTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp); if (allBelow) { if (audioTrack.closed) { + // We can write all packets since they're all below audioEndIndex = audioTrack.packets.length; } else { + // We don't know enough packets yet return; } } else { + // Aim to make the segment at most as long as desired const index = findLastIndex(audioTrack.packets, x => x.timestamp <= currentSegmentEndTimestamp); - if (index !== -1) { - audioEndIndex = index; - } else { - audioEndIndex = 1; - } + audioEndIndex = Math.max(index, 1); // Always include at least the first packet } } diff --git a/src/target.ts b/src/target.ts index 01ff9cb..891442f 100644 --- a/src/target.ts +++ b/src/target.ts @@ -72,7 +72,7 @@ export abstract class Target extends EventEmitter { this._emit('write', { start, end }); } -/** + /** * Returns a new {@link RangedTarget} that writes data to this target using the given offset. * * Useful for writing a file into a section of a larger file. diff --git a/test/browser/conversion.test.ts b/test/browser/conversion.test.ts index fe9a2ad..727c852 100644 --- a/test/browser/conversion.test.ts +++ b/test/browser/conversion.test.ts @@ -8,10 +8,11 @@ import { BufferTarget, PathedTarget } from '../../src/target.js'; import { Conversion } from '../../src/conversion.js'; import { assert } from '../../src/misc.js'; import { InputVideoTrack } from '../../src/input-track.js'; -import { AudioBufferSource, CanvasSource } from '../../src/media-source.js'; +import { CanvasSource, EncodedAudioPacketSource } from '../../src/media-source.js'; import { QUALITY_HIGH } from '../../src/encode.js'; +import { EncodedPacket } from '../../src/packet.js'; -test('Rotation is baked-in when rerendering', async () => { +test('Rotation is baked in when rerendering', async () => { using input = new Input({ source: new UrlSource('/rotate-buck-bunny.mp4'), formats: ALL_FORMATS, @@ -62,7 +63,7 @@ test('Exceeding max allowed track count', async () => { target: new BufferTarget(), }); - const conversion = await Conversion.init({ input, output }); + const conversion = await Conversion.init({ input, output, showWarnings: false }); expect(conversion.utilizedTracks).toHaveLength(1); expect(conversion.discardedTracks).toHaveLength(1); expect(conversion.discardedTracks[0]!.reason).toBe('max_track_count_reached'); @@ -84,6 +85,7 @@ test('Fan-out', async () => { output, video: [{ height: 480 }, { height: 360 }], audio: [], // Identical to discarding it + showWarnings: false, }); expect(conversion.utilizedTracks).toHaveLength(2); expect(conversion.discardedTracks).toHaveLength(1); @@ -103,21 +105,25 @@ test('Fan-out', async () => { expect(await tracks[1]!.getDisplayHeight()).toBe(360); }); -const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => { - const buffer = new AudioBuffer({ - sampleRate, - numberOfChannels: channels, - length: sampleRate * durationSeconds, - }); +// eslint-disable-next-line @stylistic/max-len +const aacPacketData = new Uint8Array([255, 241, 77, 128, 3, 159, 252, 0, 208, 0, 1, 3, 64, 0, 13, 0, 0, 17, 52, 0, 0, 208, 0, 3, 6, 128, 0, 56]); +const aacMetadata: EncodedAudioChunkMetadata = { + decoderConfig: { + codec: 'mp4a.40.2', + numberOfChannels: 2, + sampleRate: 48000, + }, +}; - for (let ch = 0; ch < channels; ch++) { - const data = buffer.getChannelData(ch); - for (let i = 0; i < data.length; i++) { - data[i] = Math.sin(2 * Math.PI * 440 * i / sampleRate); - } +const addAacPackets = async (source: EncodedAudioPacketSource, durationSeconds: number) => { + const packetDuration = 1024 / 48000; + const count = Math.ceil(durationSeconds / packetDuration); + for (let i = 0; i < count; i++) { + await source.add( + new EncodedPacket(aacPacketData, 'key', i * packetDuration, packetDuration), + i === 0 ? aacMetadata : undefined, + ); } - - return buffer; }; test('HLS track assignability is kept #1', async () => { @@ -148,7 +154,7 @@ test('HLS track assignability is kept #1', async () => { const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); output.addVideoTrack(videoSource); - const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + const audioSource = new EncodedAudioPacketSource('aac'); output.addAudioTrack(audioSource); await output.start(); @@ -157,7 +163,7 @@ test('HLS track assignability is kept #1', async () => { await videoSource.add(i / 2, 1 / 2); } - await audioSource.add(createSineWave(48000, 2, 2)); + await addAacPackets(audioSource, 2); await output.finalize(); @@ -227,7 +233,7 @@ test('HLS track assignability is kept #2', async () => { const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); output.addVideoTrack(videoSource, { group: a }); - const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + const audioSource = new EncodedAudioPacketSource('aac'); output.addAudioTrack(audioSource, { group: b }); await output.start(); @@ -236,7 +242,7 @@ test('HLS track assignability is kept #2', async () => { await videoSource.add(i / 2, 1 / 2); } - await audioSource.add(createSineWave(48000, 2, 2)); + await addAacPackets(audioSource, 2); await output.finalize(); @@ -306,7 +312,7 @@ test('HLS track assignability can be overridden', async () => { const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); output.addVideoTrack(videoSource, { group: a }); - const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + const audioSource = new EncodedAudioPacketSource('aac'); output.addAudioTrack(audioSource, { group: b }); await output.start(); @@ -315,7 +321,7 @@ test('HLS track assignability can be overridden', async () => { await videoSource.add(i / 2, 1 / 2); } - await audioSource.add(createSineWave(48000, 2, 2)); + await addAacPackets(audioSource, 2); await output.finalize(); diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 292c35e..7aef616 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -1487,6 +1487,60 @@ segment-1-3.ts ); }); +test('Segmentation, one video packet per segment', async () => { + const env = await setUpSegmentationEnvironment({ video: true }); + + await env.addVideoPacket(0, 'key', 3); + expect(env.segmentCount).toBe(0); + await env.addVideoPacket(3, 'key', 3); + expect(env.segmentCount).toBe(1); + + await env.output.finalize(); + expect(env.segmentCount).toBe(2); + + expect(env.result).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-TARGETDURATION:3 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:3, +segment-1-1.ts +#EXTINF:3, +segment-1-2.ts + +#EXT-X-ENDLIST +`, + ); +}); + +test('Segmentation, one audio packet per segment', async () => { + const env = await setUpSegmentationEnvironment({ audio: true }); + + await env.addAudioPacket(0, 3); + expect(env.segmentCount).toBe(0); + await env.addAudioPacket(3, 3); + expect(env.segmentCount).toBe(1); + + await env.output.finalize(); + expect(env.segmentCount).toBe(2); + + expect(env.result).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-TARGETDURATION:3 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:3, +segment-1-1.ts +#EXTINF:3, +segment-1-2.ts + +#EXT-X-ENDLIST +`, + ); +}); + test('Segmentation, dual-track, single segment', async () => { const env = await setUpSegmentationEnvironment({ video: true, audio: true });