Skip to content

Commit dfc024d

Browse files
committed
Audio in clips
1 parent cb13e05 commit dfc024d

2 files changed

Lines changed: 121 additions & 16 deletions

File tree

apps/gif-service/src/emulator.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export class Emulator {
4646
private tape: TAPFile | null = null;
4747
private tapePulses: Uint16Array | null = null;
4848
private tapeIsPlaying: boolean = false;
49+
// Audio is generated only when a non-zero samples-per-frame is requested.
50+
// Each frame's stereo samples are copied out of WASM memory before the next
51+
// frame overwrites them.
52+
private audioSamplesPerFrame: number = 0;
53+
private lastAudioLeft: Float32Array | null = null;
54+
private lastAudioRight: Float32Array | null = null;
4955

5056
// ZX Spectrum keyboard matrix (character -> [row, bitmask]).
5157
// Rows/masks match the core's keyDown(row, mask) convention used by the web
@@ -137,6 +143,19 @@ export class Emulator {
137143
return this.tapeIsPlaying;
138144
}
139145

146+
// Ask the core to render `samplesPerFrame` stereo audio samples each frame
147+
// (0 disables audio generation entirely, the default).
148+
enableAudio(samplesPerFrame: number): void {
149+
this.audioSamplesPerFrame = samplesPerFrame;
150+
}
151+
152+
// The stereo samples produced by the most recent runFrame, or null if audio
153+
// is disabled. The arrays are copies, safe to retain past the next frame.
154+
getLastAudio(): { left: Float32Array; right: Float32Array } | null {
155+
if (!this.lastAudioLeft || !this.lastAudioRight) return null;
156+
return { left: this.lastAudioLeft, right: this.lastAudioRight };
157+
}
158+
140159
private trapTapeLoad(): void {
141160
if (!this.tape || !this.core || !this.registerPairs) {
142161
console.log('trapTapeLoad: missing tape, core, or registers');
@@ -203,7 +222,7 @@ export class Emulator {
203222
if (!this.core || !this.frameData) {
204223
throw new Error('Core not loaded');
205224
}
206-
this.core.setAudioSamplesPerFrame(0);
225+
this.core.setAudioSamplesPerFrame(this.audioSamplesPerFrame);
207226

208227
// Handle tape pulse generation if tape is playing
209228
if (this.tape && this.tapeIsPlaying && this.tapePulses) {
@@ -235,6 +254,15 @@ export class Emulator {
235254
}
236255
status = this.core.resumeFrame();
237256
}
257+
258+
if (this.audioSamplesPerFrame > 0) {
259+
// Copy out of WASM memory: the views alias the heap, which the next
260+
// frame overwrites, so slice() to detach a standalone buffer.
261+
const n = this.audioSamplesPerFrame;
262+
this.lastAudioLeft = new Float32Array(this.core.memory.buffer, this.core.AUDIO_BUFFER_LEFT, n).slice();
263+
this.lastAudioRight = new Float32Array(this.core.memory.buffer, this.core.AUDIO_BUFFER_RIGHT, n).slice();
264+
}
265+
238266
return new Uint8Array(this.frameData);
239267
}
240268

apps/gif-service/src/gif-generator.ts

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@ import { spawn } from 'child_process';
33
import { tmpdir } from 'os';
44
import { Emulator } from './emulator.js';
55
import { FrameDecoder } from './frame-decoder.js';
6-
import { readFile, unlink } from 'fs/promises';
6+
import { readFile, unlink, writeFile } from 'fs/promises';
77
import { join, dirname } from 'path';
88
import { fileURLToPath } from 'url';
99

1010
const __filename = fileURLToPath(import.meta.url);
1111
const __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+
1322
export 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(/\.mp4$/, '.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

Comments
 (0)