diff --git a/.github/workflows/player.yml b/.github/workflows/player.yml index b8470c4..185f85c 100644 --- a/.github/workflows/player.yml +++ b/.github/workflows/player.yml @@ -58,6 +58,10 @@ jobs: env: NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs run: bun web/scripts/check-parties.mjs + - name: Real HLS playlist decoding across mixed source files + env: + NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs + run: bun web/scripts/check-media-playlist.mjs - name: Live playlist row stability and mouse scrolling env: NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs diff --git a/package.json b/package.json index 161a141..434ec3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nixamp", - "version": "0.28.36", + "version": "0.28.37", "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.", "license": "MIT", "type": "module", diff --git a/src/channels.ts b/src/channels.ts index 2537cd4..5c3cfa0 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -455,8 +455,11 @@ export class Channel { ? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) } : this.options.through?.(this.info, from, input, audio) ?? null; this.info.teed = through !== null && !playlistInput; + // AAC's AudioSpecificConfig is produced by the bitstream filter on + // its first packet. Writing an empty moov earlier omits that config: + // HLS remuxing then emits AAC with no ADTS headers and browsers fail. const playlistEncode = !playlistInput ? null : this.info.kind === "video" - ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"] + ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof+delay_moov"] : ["-af", "aresample=async=1:first_pts=0", ...encode]; const child = spawn( command, @@ -792,7 +795,9 @@ export class Channel { // few kilobytes (slides, static cameras, or a paused game screen). while (this.recent.length > 0) { const oldest = this.fragmentTimes.get(this.recent[0]!); - if (oldest === undefined || time - oldest < BACKLOG_SECONDS) break; + // Audio frame rounding and floating-point subtraction must not + // retain one extra two-second fragment at the window boundary. + if (oldest === undefined || time - oldest < BACKLOG_SECONDS - 0.05) break; do { this.recentBytes -= this.recent.shift()!.byteLength; } while (this.recent.length && boxType(this.recent[0]!) !== "moof"); } diff --git a/test/playlist-stream.test.ts b/test/playlist-stream.test.ts index 5002e3b..3d3dbdc 100644 --- a/test/playlist-stream.test.ts +++ b/test/playlist-stream.test.ts @@ -47,6 +47,9 @@ test("a directory live crosses codecs and subdirectories in one decodable stream const bytes = Buffer.concat(chunks); assert.equal(boxes(bytes).filter(one => one.type === "moov").length, 1, "one stream header across every file"); const out = join(root, "out.mp4"); writeFileSync(out, bytes); + const probed = JSON.parse(execFileSync("ffprobe", ["-v", "error", "-show_entries", "stream=codec_name,extradata_size", "-of", "json", out], { encoding: "utf8" })); + assert.ok(probed.streams.find((stream: { codec_name: string }) => stream.codec_name === "aac")?.extradata_size >= 2, + "the initial MP4 header must contain AAC config before an HLS packager or browser receives it"); const pixels = ff(["-i", out, "-an", "-vf", "scale=1:1", "-pix_fmt", "rgb24", "-f", "rawvideo", "pipe:1"]); const colors: string[] = []; for (let at = 0; at < pixels.length; at += 3) { diff --git a/web/scripts/check-media-playlist.mjs b/web/scripts/check-media-playlist.mjs new file mode 100644 index 0000000..3e0d37d --- /dev/null +++ b/web/scripts/check-media-playlist.mjs @@ -0,0 +1,58 @@ +// Real encoders, HLS packaging, and browser decoding; synthetic media only. +import assert from 'node:assert/strict'; +import {mkdtempSync,writeFileSync,readFileSync,readdirSync,statSync,rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {fileURLToPath} from 'node:url'; +import {execFileSync} from 'node:child_process'; +import {Channels} from '../../src/channels.ts'; +const {chromium}=await import(process.env.NIXAMP_PLAYWRIGHT_MODULE || 'playwright'); +const root=mkdtempSync(join(tmpdir(),'nixamp-browser-media-')); +const assets=fileURLToPath(new URL('../dist/assets',import.meta.url)); +const ff=args=>execFileSync('ffmpeg',['-hide_banner','-loglevel','error','-y',...args],{timeout:20000}); +let browser,channels; +try{ + const files=['red','blue','lime'].map((color,i)=>{ + const file=join(root,`${i}.mp4`); + ff(['-f','lavfi','-i',`color=c=${color}:s=160x90:r=10:d=2`,'-f','lavfi','-i',`sine=frequency=${440+i*220}:sample_rate=${i===1?44100:48000}:duration=2`, + '-c:v',i===1?'mpeg4':'libx264','-threads','1','-g','10','-c:a','aac','-t','2',file]); + return file; + }); + let finish;const done=new Promise(r=>finish=r); + channels=new Channels({ffmpeg:['ffmpeg'],ffprobe:['ffprobe'],onEnd:finish}); + const channel=channels.pull('test','Test',files[0],[],'video',false,10000,[],'',{live:true,position:0,playlist:files},{video:'h264',audio:'aac',container:'mp4',width:160,height:90}); + const chunks=[];channel.listen({write(chunk){chunks.push(chunk);return true},end(){}}); + await done;assert.equal(channel.info.error,undefined); + const mp4=join(root,'live.mp4');writeFileSync(mp4,Buffer.concat(chunks)); + ff(['-i',mp4,'-c','copy','-f','hls','-hls_time','2','-hls_list_size','0','-hls_flags','independent_segments','-hls_segment_filename',join(root,'seg%d.ts'),join(root,'index.m3u8')]); + const library=readdirSync(assets).filter(name=>/^hls-.*\.js$/.test(name)).sort((a,b)=>statSync(join(assets,b)).size-statSync(join(assets,a)).size)[0]; + browser=await chromium.launch({headless:true,...(process.env.NIXAMP_CHROMIUM_PATH?{executablePath:process.env.NIXAMP_CHROMIUM_PATH}:{}),args:['--autoplay-policy=no-user-gesture-required']}); + const page=await browser.newPage({serviceWorkers:'block'}); + await page.route('https://media.example/**',route=>{ + const path=new URL(route.request().url()).pathname; + if(path==='/')return route.fulfill({contentType:'text/html',body:''}); + const file=path.startsWith('/assets/')?join(assets,path.slice(8)):join(root,path.slice(1)); + return route.fulfill({body:readFileSync(file),contentType:path.endsWith('.js')?'text/javascript':path.endsWith('.m3u8')?'application/vnd.apple.mpegurl':'video/mp2t'}); + }); + await page.goto('https://media.example/'); + await page.evaluate(async library=>{ + const {default:Hls}=await import(`/assets/${library}`); + const video=document.querySelector('video'); + window.mediaErrors=[];window.colors=[]; + video.addEventListener('error',()=>window.mediaErrors.push(video.error?.message)); + const hls=new Hls();window.testHls=hls; + hls.on(Hls.Events.ERROR,(_e,d)=>{if(d.fatal||d.details==='fragParsingError')window.mediaErrors.push(d.details)}); + const context=document.querySelector('canvas').getContext('2d'); + video.addEventListener('timeupdate',()=>{ + if(!video.videoWidth)return; + context.drawImage(video,0,0,1,1);const [r,g,b]=context.getImageData(0,0,1,1).data; + const color=r>150?'red':b>150?'blue':g>150?'green':'unknown'; + if(color!==window.colors.at(-1))window.colors.push(color); + }); + hls.attachMedia(video);hls.loadSource('/index.m3u8');await video.play(); + },library); + await page.waitForFunction(()=>document.querySelector('video').ended,{},{timeout:15000}); + const result=await page.evaluate(()=>({errors:window.mediaErrors,colors:window.colors,time:document.querySelector('video').currentTime})); + assert.deepEqual(result.errors,[]);assert.deepEqual(result.colors,['red','blue','green']);assert.ok(result.time>=5.9); + console.log('Real HLS video and audio decode through three files and two source codecs with no parsing or media errors.'); +}finally{channels?.stopAll();await browser?.close();rmSync(root,{recursive:true,force:true})} diff --git a/web/src/player.ts b/web/src/player.ts index fa2793f..e54faba 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -194,6 +194,7 @@ export class BrowserPlayer { constructor( private readonly elements: PlayerElements, private readonly handlers: PlayerHandlers, + private readonly attach: typeof attachSource = attachSource, ) { this.active = elements.audio; for (const element of [elements.audio, elements.video]) { @@ -217,7 +218,7 @@ export class BrowserPlayer { if (element === this.active) this.handlers.onState(false); }); element.addEventListener("error", () => { - if (element === this.active) this.handlers.onError(mediaError(element)); + if (element === this.active && (!this.attached || this.attached.engine === "native")) this.handlers.onError(mediaError(element)); }); const busy = (is: boolean) => () => { if (element === this.active) this.handlers.onBusy?.(is); @@ -391,15 +392,18 @@ export class BrowserPlayer { this.attached?.destroy(); this.attached = null; try { - this.attached = await attachSource(this.active, { + this.attached = await this.attach(this.active, { src: track.url, kind, // A film the browser has no decoder for is the ordinary case in a // library of downloads, and silence is the worst way to say so. unplayableAdvice: "VLC or mpv will play it; nixamp can only hand it to your browser.", onError: (message) => this.handlers.onError(message), + // The engine owns transient recovery. Turning its notice into an + // error reloads the source in the middle of recoverMediaError/startLoad + // and spends the room's rejoin budget before playback can resume. onNotice: (message) => { - if (message) this.handlers.onError(message); + if (message) this.handlers.onBusy?.(true); }, }); } catch (error) { @@ -432,6 +436,9 @@ export class BrowserPlayer { this.handlers.onState(false); return; } + // HLS/MSE recovery replaces its MediaSource and aborts the pending play + // request. The engine still owns that recovery, including terminal errors. + if (error instanceof Error && error.name === "AbortError" && this.attached?.engine !== "native") return; this.handlers.onError(error instanceof Error ? error.message : "playback was refused"); } } diff --git a/web/test/player.test.ts b/web/test/player.test.ts index c1ab612..8039fc8 100644 --- a/web/test/player.test.ts +++ b/web/test/player.test.ts @@ -164,3 +164,28 @@ test('the intro retries a suspended graph once per media element and leaves the else Reflect.deleteProperty(globalThis, 'AudioContext'); } }); + +test("engine recovery notices and aborted play requests do not reload a live source", async () => { + const audio = new MediaElement(); + const errors: string[] = []; + const busy: boolean[] = []; + let sourceOptions: Parameters[1] | undefined; + let destroyed = 0; + const player = new BrowserPlayer({ audio: audio as unknown as HTMLAudioElement, video: new MediaElement() as unknown as HTMLVideoElement }, { + onTime() {}, onEnded() {}, onState() {}, onError: message => errors.push(message), onBusy: value => busy.push(value), + }, async (_media, options) => { + sourceOptions = options; + return { engine: "hls", kind: "hls", levels: () => [], destroy() { destroyed++; } }; + }); + await player.load({ title: "Live", artist: "", album: "", duration: 0, url: "https://server.example/live", video: false, objectUrl: false, kind: "audio" }, false); + sourceOptions!.onNotice?.("Recovering…"); + audio.dispatchEvent(new Event("error")); + audio.refusal = new DOMException("MediaSource replaced during recovery", "AbortError"); + await player.play(); + assert.deepEqual(errors, [], "transient engine recovery must not consume the room's rejoin budget"); + assert.equal(destroyed, 0); + assert.ok(busy.includes(true)); + sourceOptions!.onError?.("Recovery failed"); + assert.deepEqual(errors, ["Recovery failed"], "terminal engine failures still reach the app"); + player.stop(); +});