@@ -3,13 +3,22 @@ import { spawn } from 'child_process';
33import { tmpdir } from 'os' ;
44import { Emulator } from './emulator.js' ;
55import { FrameDecoder } from './frame-decoder.js' ;
6- import { readFile , unlink } from 'fs/promises' ;
6+ import { readFile , unlink , writeFile } from 'fs/promises' ;
77import { join , dirname } from 'path' ;
88import { fileURLToPath } from 'url' ;
99
1010const __filename = fileURLToPath ( import . meta. url ) ;
1111const __dirname = dirname ( __filename ) ;
1212
13+ const SAMPLE_RATE = 44100 ;
14+ const SAMPLES_PER_FRAME = SAMPLE_RATE / 50 ; // 882 stereo samples per 50fps frame
15+ const AUDIO_SILENCE_EPS = 0.001 ; // a channel whose peak-to-peak is below this is flat (silent)
16+
17+ interface AudioFrame {
18+ left : Float32Array ;
19+ right : Float32Array ;
20+ }
21+
1322export interface GIFGeneratorOptions {
1423 maxDurationMs : number ;
1524 staleFrameThreshold : number ;
@@ -71,9 +80,14 @@ export class GIFGenerator {
7180 * Boot the machine, load the tape, run the program, and return the kept raw
7281 * frame buffers (50fps, trailing static trimmed). Shared by both encoders.
7382 */
74- private async captureFrames ( tapData : Buffer , machineType : number ) : Promise < Uint8Array [ ] > {
83+ private async captureFrames (
84+ tapData : Buffer ,
85+ machineType : number ,
86+ captureAudio : boolean ,
87+ ) : Promise < { frames : Uint8Array [ ] ; audio : AudioFrame [ ] } > {
7588 this . emulator . setMachineType ( machineType ) ;
7689 this . emulator . setTapeTraps ( true ) ; // instant block loading; no pulse playback needed
90+ this . emulator . enableAudio ( captureAudio ? SAMPLES_PER_FRAME : 0 ) ;
7791 this . emulator . loadTAPFile ( tapData ) ;
7892 this . emulator . reset ( ) ;
7993
@@ -105,6 +119,7 @@ export class GIFGenerator {
105119 const tailFrames = 25 ; // ~0.5s of static tail kept for readability
106120
107121 const frames : Uint8Array [ ] = [ ] ;
122+ const audio : AudioFrame [ ] = [ ] ;
108123 let previousFrame : Uint8Array | null = null ;
109124 let staleCount = 0 ;
110125 let lastChangeIndex = - 1 ;
@@ -113,7 +128,21 @@ export class GIFGenerator {
113128 const frameBuffer = new Uint8Array ( this . emulator . runFrame ( ) ) ;
114129 frames . push ( frameBuffer ) ;
115130
116- if ( previousFrame && this . areFramesIdentical ( frameBuffer , previousFrame ) ) {
131+ // Audio activity counts as a change: a tune over a static screen must
132+ // not be cut short, nor its tail trimmed. Kept aligned 1:1 with frames.
133+ let audioSilent = true ;
134+ if ( captureAudio ) {
135+ const a = this . emulator . getLastAudio ( ) ?? {
136+ left : new Float32Array ( SAMPLES_PER_FRAME ) ,
137+ right : new Float32Array ( SAMPLES_PER_FRAME ) ,
138+ } ;
139+ audio . push ( a ) ;
140+ audioSilent = this . isAudioSilent ( a . left , a . right ) ;
141+ }
142+
143+ const videoStatic =
144+ previousFrame !== null && this . areFramesIdentical ( frameBuffer , previousFrame ) ;
145+ if ( videoStatic && audioSilent ) {
117146 staleCount ++ ;
118147 if ( staleCount >= staleStop ) {
119148 console . log ( `Program settled after ${ f + 1 } captured frames` ) ;
@@ -132,12 +161,29 @@ export class GIFGenerator {
132161 }
133162 const keep = Math . min ( frames . length , lastChangeIndex + 1 + tailFrames ) ;
134163 console . log ( `Captured ${ frames . length } frames, keeping ${ keep } ` ) ;
135- return frames . slice ( 0 , keep ) ;
164+ return { frames : frames . slice ( 0 , keep ) , audio : audio . slice ( 0 , keep ) } ;
136165 }
137166
138- /** Render the program to an animated GIF (25fps to keep size sane). */
167+ private isAudioSilent ( left : Float32Array , right : Float32Array ) : boolean {
168+ return this . channelFlat ( left ) && this . channelFlat ( right ) ;
169+ }
170+
171+ // A channel is "silent" when it is flat: an idle beeper/AY holds a constant
172+ // (often non-zero) DC level, so detect a lack of oscillation, not zero.
173+ private channelFlat ( samples : Float32Array ) : boolean {
174+ let min = Infinity ;
175+ let max = - Infinity ;
176+ for ( let i = 0 ; i < samples . length ; i ++ ) {
177+ const v = samples [ i ] ;
178+ if ( v < min ) min = v ;
179+ if ( v > max ) max = v ;
180+ }
181+ return max - min < AUDIO_SILENCE_EPS ;
182+ }
183+
184+ /** Render the program to an animated GIF (25fps to keep size sane). GIF carries no audio. */
139185 async generateFromTAP ( tapData : Buffer , machineType : number = 48 ) : Promise < Buffer > {
140- const frames = await this . captureFrames ( tapData , machineType ) ;
186+ const { frames } = await this . captureFrames ( tapData , machineType , false ) ;
141187
142188 // The core runs at 50fps; encode every 2nd frame (25fps) to halve GIF
143189 // size and encode time with little visible loss.
@@ -155,26 +201,56 @@ export class GIFGenerator {
155201 return Buffer . from ( encoder . out . getData ( ) ) ;
156202 }
157203
158- /** Render the program to an H.264 MP4 at the full 50fps. */
204+ /** Render the program to an H.264 MP4 at the full 50fps, with AAC audio . */
159205 async generateMp4FromTAP ( tapData : Buffer , machineType : number = 48 ) : Promise < Buffer > {
160- const frames = await this . captureFrames ( tapData , machineType ) ;
161- return this . encodeMp4 ( frames , 50 ) ;
206+ const { frames, audio } = await this . captureFrames ( tapData , machineType , true ) ;
207+ return this . encodeMp4 ( frames , audio , 50 ) ;
208+ }
209+
210+ // Concatenate per-frame stereo audio into one interleaved (L,R,L,R) f32 buffer.
211+ private interleaveAudio ( audio : AudioFrame [ ] ) : Buffer {
212+ const total = audio . reduce ( ( sum , a ) => sum + a . left . length , 0 ) ;
213+ const interleaved = new Float32Array ( total * 2 ) ;
214+ let p = 0 ;
215+ for ( const { left, right } of audio ) {
216+ for ( let i = 0 ; i < left . length ; i ++ ) {
217+ interleaved [ p ++ ] = left [ i ] ;
218+ interleaved [ p ++ ] = right [ i ] ;
219+ }
220+ }
221+ return Buffer . from ( interleaved . buffer , interleaved . byteOffset , interleaved . byteLength ) ;
162222 }
163223
164224 /** Pipe decoded RGBA frames through ffmpeg to a temporary MP4 and return it. */
165- private async encodeMp4 ( frames : Uint8Array [ ] , fps : number ) : Promise < Buffer > {
225+ private async encodeMp4 ( frames : Uint8Array [ ] , audio : AudioFrame [ ] , fps : number ) : Promise < Buffer > {
166226 const width = this . decoder . getWidth ( ) ;
167227 const height = this . decoder . getHeight ( ) ;
168228 const outPath = join ( tmpdir ( ) , `zxplay-${ process . pid } -${ Date . now ( ) } .mp4` ) ;
169229
230+ // Audio (if any) goes via a temp f32le file as a second ffmpeg input;
231+ // video stays on stdin. Both run frames/50 seconds long, so they align.
232+ const hasAudio = audio . length > 0 ;
233+ const audioPath = outPath . replace ( / \. m p 4 $ / , '.f32le' ) ;
234+ if ( hasAudio ) {
235+ await writeFile ( audioPath , this . interleaveAudio ( audio ) ) ;
236+ }
237+
170238 const args = [
171239 '-f' , 'rawvideo' , '-pix_fmt' , 'rgba' , '-s' , `${ width } x${ height } ` , '-r' , String ( fps ) ,
172240 '-i' , 'pipe:0' ,
173- '-an' , // no audio
174- '-c:v' , 'libx264' , '-pix_fmt' , 'yuv420p' , '-preset' , 'veryfast' , '-crf' , '20' ,
175- '-movflags' , '+faststart' ,
176- '-y' , outPath ,
177241 ] ;
242+ if ( hasAudio ) {
243+ args . push ( '-f' , 'f32le' , '-ar' , String ( SAMPLE_RATE ) , '-ac' , '2' , '-i' , audioPath ) ;
244+ }
245+ args . push (
246+ '-c:v' , 'libx264' , '-pix_fmt' , 'yuv420p' , '-preset' , 'veryfast' , '-crf' , '20' ,
247+ ) ;
248+ if ( hasAudio ) {
249+ args . push ( '-c:a' , 'aac' , '-b:a' , '128k' , '-shortest' ) ;
250+ } else {
251+ args . push ( '-an' ) ;
252+ }
253+ args . push ( '-movflags' , '+faststart' , '-y' , outPath ) ;
178254 const ff = spawn ( 'ffmpeg' , args , { stdio : [ 'pipe' , 'ignore' , 'pipe' ] } ) ;
179255 let stderr = '' ;
180256 ff . stderr . on ( 'data' , ( d ) => { stderr += d . toString ( ) ; } ) ;
@@ -197,7 +273,8 @@ export class GIFGenerator {
197273
198274 const buffer = await readFile ( outPath ) ;
199275 await unlink ( outPath ) . catch ( ( ) => undefined ) ;
200- console . log ( `Encoded MP4: ${ frames . length } frames, ${ buffer . length } bytes` ) ;
276+ if ( hasAudio ) await unlink ( audioPath ) . catch ( ( ) => undefined ) ;
277+ console . log ( `Encoded MP4: ${ frames . length } frames, ${ buffer . length } bytes${ hasAudio ? ' (with audio)' : '' } ` ) ;
201278 return buffer ;
202279 }
203280
0 commit comments