Skip to content

Commit b4de04e

Browse files
committed
feat(audio): add recording support to WebAudioLayer
Add optional recording methods to AudioLayer interface and implement them in WebAudioLayer: - enableRecording(): creates MediaStreamAudioDestinationNode - disableRecording(): disconnects recording destination - getRecordingStream(): returns MediaStream for recording Implementation changes: - Add masterGain node for unified audio routing - Route all audio through master gain for recording capture - Clean up recording on destroy Add comprehensive tests: - Unit tests for all recording methods (11 new tests) - Playwright browser tests (6 new tests) - Update browser.setup.ts mocks for MediaStream support
1 parent 4f0fa32 commit b4de04e

8 files changed

Lines changed: 315 additions & 15 deletions

File tree

.versionrc.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
{
2-
"packageFiles": [
3-
"packages/core/package.json"
4-
],
5-
"bumpFiles": [
6-
"packages/core/package.json"
7-
],
2+
"packageFiles": ["packages/core/package.json"],
3+
"bumpFiles": ["packages/core/package.json"],
84
"infile": "CHANGELOG.md",
95
"header": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n",
106
"types": [

packages/core/src/platform/AudioLayer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,9 @@ export interface AudioLayer {
5252
// Context management
5353
tryResumeOnce(): Promise<void>
5454
getState(): AudioContextState
55+
56+
// Recording (optional - only implemented by WebAudioLayer)
57+
enableRecording?(): void
58+
disableRecording?(): void
59+
getRecordingStream?(): MediaStream | null
5560
}

packages/core/tests/browser/web-audio.spec.playwright.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,146 @@ test.describe("WebAudioLayer Autoplay Policy Fix", () => {
484484
})
485485
})
486486

487+
test.describe("WebAudioLayer Recording", () => {
488+
test.beforeEach(async ({ page }) => {
489+
await page.goto("http://localhost:3000/tests/browser/test-page.html")
490+
})
491+
492+
test("should return null stream before recording enabled", async ({
493+
page,
494+
}) => {
495+
const result = await page.evaluate(
496+
`(async () => {
497+
${getAudioSetup()}
498+
499+
return {
500+
stream: audio.getRecordingStream(),
501+
}
502+
})()`,
503+
)
504+
505+
expect(result.stream).toBeNull()
506+
})
507+
508+
test("should enable recording and return MediaStream", async ({ page }) => {
509+
const result = await page.evaluate(
510+
`(async () => {
511+
${getAudioSetup()}
512+
513+
audio.enableRecording()
514+
const stream = audio.getRecordingStream()
515+
516+
return {
517+
hasStream: stream !== null,
518+
isMediaStream: stream instanceof MediaStream,
519+
streamActive: stream ? stream.active : false,
520+
}
521+
})()`,
522+
)
523+
524+
expect(result.hasStream).toBe(true)
525+
expect(result.isMediaStream).toBe(true)
526+
})
527+
528+
test("should disable recording and return null stream", async ({ page }) => {
529+
const result = await page.evaluate(
530+
`(async () => {
531+
${getAudioSetup()}
532+
533+
audio.enableRecording()
534+
const streamBefore = audio.getRecordingStream()
535+
536+
audio.disableRecording()
537+
const streamAfter = audio.getRecordingStream()
538+
539+
return {
540+
hadStreamBefore: streamBefore !== null,
541+
hasStreamAfter: streamAfter !== null,
542+
}
543+
})()`,
544+
)
545+
546+
expect(result.hadStreamBefore).toBe(true)
547+
expect(result.hasStreamAfter).toBe(false)
548+
})
549+
550+
test("should handle enable/disable cycle multiple times", async ({
551+
page,
552+
}) => {
553+
const result = await page.evaluate(
554+
`(async () => {
555+
${getAudioSetup()}
556+
557+
const states = []
558+
559+
for (let i = 0; i < 3; i++) {
560+
audio.enableRecording()
561+
states.push(audio.getRecordingStream() !== null)
562+
audio.disableRecording()
563+
states.push(audio.getRecordingStream() === null)
564+
}
565+
566+
return { states }
567+
})()`,
568+
)
569+
570+
expect(result.states).toEqual([true, true, true, true, true, true])
571+
})
572+
573+
test("should cleanup recording on destroy", async ({ page }) => {
574+
const result = await page.evaluate(
575+
`(async () => {
576+
${getAudioSetup()}
577+
578+
audio.enableRecording()
579+
const streamBefore = audio.getRecordingStream()
580+
581+
audio.destroy()
582+
const streamAfter = audio.getRecordingStream()
583+
584+
return {
585+
hadStreamBefore: streamBefore !== null,
586+
hasStreamAfter: streamAfter !== null,
587+
}
588+
})()`,
589+
)
590+
591+
expect(result.hadStreamBefore).toBe(true)
592+
expect(result.hasStreamAfter).toBe(false)
593+
})
594+
595+
test("should allow sound playback while recording", async ({ page }) => {
596+
const result = await page.evaluate(
597+
`(async () => {
598+
${getAudioSetup()}
599+
${CREATE_TEST_BUFFER}
600+
601+
const buffer = createTestBuffer(audio, 440, 0.05)
602+
audio.loadSoundFromBuffer("test-tone", buffer)
603+
604+
await audio.tryResumeOnce()
605+
606+
audio.enableRecording()
607+
const stream = audio.getRecordingStream()
608+
609+
audio.playSound("test-tone", 0.5, false)
610+
611+
await new Promise(resolve => setTimeout(resolve, 50))
612+
613+
audio.stopSound("test-tone")
614+
615+
return {
616+
hasStream: stream !== null,
617+
state: audio.getState(),
618+
}
619+
})()`,
620+
)
621+
622+
expect(result.hasStream).toBe(true)
623+
expect(result.state).toBe(AudioContextState.RUNNING)
624+
})
625+
})
626+
487627
test.describe("WebAudioLayer Data URL Loading", () => {
488628
test.beforeEach(async ({ page }) => {
489629
await page.goto("http://localhost:3000/tests/browser/test-page.html")

packages/core/tests/platform/WebAudioLayer.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,78 @@ describe("WebAudioLayer", () => {
178178
expect(() => audio.playSound("test")).not.toThrow()
179179
})
180180
})
181+
182+
describe("Recording", () => {
183+
beforeEach(async () => {
184+
await audio.initialize()
185+
})
186+
187+
it("should return null stream before recording enabled", () => {
188+
expect(audio.getRecordingStream()).toBeNull()
189+
})
190+
191+
it("should enable recording without errors", () => {
192+
expect(() => audio.enableRecording()).not.toThrow()
193+
})
194+
195+
it("should return MediaStream when recording enabled", () => {
196+
audio.enableRecording()
197+
const stream = audio.getRecordingStream()
198+
expect(stream).not.toBeNull()
199+
})
200+
201+
it("should be idempotent when enabling recording multiple times", () => {
202+
audio.enableRecording()
203+
const stream1 = audio.getRecordingStream()
204+
audio.enableRecording()
205+
const stream2 = audio.getRecordingStream()
206+
expect(stream1).toBe(stream2)
207+
})
208+
209+
it("should disable recording without errors", () => {
210+
audio.enableRecording()
211+
expect(() => audio.disableRecording()).not.toThrow()
212+
})
213+
214+
it("should return null stream after recording disabled", () => {
215+
audio.enableRecording()
216+
expect(audio.getRecordingStream()).not.toBeNull()
217+
audio.disableRecording()
218+
expect(audio.getRecordingStream()).toBeNull()
219+
})
220+
221+
it("should be idempotent when disabling recording multiple times", () => {
222+
audio.enableRecording()
223+
audio.disableRecording()
224+
expect(() => audio.disableRecording()).not.toThrow()
225+
expect(audio.getRecordingStream()).toBeNull()
226+
})
227+
228+
it("should handle enable/disable cycle multiple times", () => {
229+
for (let i = 0; i < 3; i++) {
230+
audio.enableRecording()
231+
expect(audio.getRecordingStream()).not.toBeNull()
232+
audio.disableRecording()
233+
expect(audio.getRecordingStream()).toBeNull()
234+
}
235+
})
236+
237+
it("should disable recording on destroy", () => {
238+
audio.enableRecording()
239+
expect(audio.getRecordingStream()).not.toBeNull()
240+
audio.destroy()
241+
expect(audio.getRecordingStream()).toBeNull()
242+
})
243+
244+
it("should handle enableRecording before initialization", () => {
245+
const uninitAudio = new WebAudioLayer()
246+
expect(() => uninitAudio.enableRecording()).not.toThrow()
247+
expect(uninitAudio.getRecordingStream()).toBeNull()
248+
})
249+
250+
it("should handle disableRecording before initialization", () => {
251+
const uninitAudio = new WebAudioLayer()
252+
expect(() => uninitAudio.disableRecording()).not.toThrow()
253+
})
254+
})
181255
})

packages/core/tests/setup/browser.setup.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ export function setupBrowserEnvironment() {
2222
global.MouseEvent = window.MouseEvent as any
2323
global.KeyboardEvent = window.KeyboardEvent as any
2424

25+
// Mock MediaStream for recording tests
26+
global.MediaStream = class MediaStream {
27+
id = "mock-stream-id"
28+
active = true
29+
getTracks() {
30+
return []
31+
}
32+
} as any
33+
2534
// Web Audio API mocks (basic stubs for testing)
2635
global.AudioContext = class AudioContext {
2736
state = "running"
@@ -50,6 +59,15 @@ export function setupBrowserEnvironment() {
5059
connect: () => {
5160
// No-op mock
5261
},
62+
disconnect: () => {
63+
// No-op mock
64+
},
65+
}
66+
}
67+
68+
createMediaStreamDestination() {
69+
return {
70+
stream: new (global as any).MediaStream(),
5371
}
5472
}
5573

@@ -91,4 +109,5 @@ export function cleanupBrowserEnvironment() {
91109
delete (global as any).MouseEvent
92110
delete (global as any).KeyboardEvent
93111
delete (global as any).AudioContext
112+
delete (global as any).MediaStream
94113
}

packages/platform-web-pixi/src/WebAudioLayer.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export class WebAudioLayer implements AudioLayer {
1414
private isClosed = false
1515
private hasResumed = false
1616

17+
// Recording support
18+
private masterGain: GainNode | null = null
19+
private recordingDestination: MediaStreamAudioDestinationNode | null = null
20+
private isRecordingEnabled = false
21+
1722
private async resumeWithTimeout(maxWaitMs = 2000): Promise<void> {
1823
if (!this.context) {
1924
return
@@ -35,6 +40,10 @@ export class WebAudioLayer implements AudioLayer {
3540
return
3641
}
3742
this.context = new AudioContext()
43+
44+
// Create master gain node for all audio routing
45+
this.masterGain = this.context.createGain()
46+
this.masterGain.connect(this.context.destination)
3847
}
3948

4049
destroy(): void {
@@ -44,6 +53,8 @@ export class WebAudioLayer implements AudioLayer {
4453
}
4554

4655
this.stopAll()
56+
this.disableRecording()
57+
this.masterGain = null
4758
this.context.close()
4859
this.context = null
4960
this.buffers.clear()
@@ -95,7 +106,7 @@ export class WebAudioLayer implements AudioLayer {
95106
}
96107

97108
playSound(id: string, volume = 1.0, loop = false): void {
98-
if (!this.context) {
109+
if (!this.context || !this.masterGain) {
99110
return
100111
}
101112

@@ -111,8 +122,9 @@ export class WebAudioLayer implements AudioLayer {
111122
const gainNode = this.context.createGain()
112123
gainNode.gain.value = volume
113124

125+
// Route through master gain for both playback and recording
114126
source.connect(gainNode)
115-
gainNode.connect(this.context.destination)
127+
gainNode.connect(this.masterGain)
116128

117129
source.start()
118130

@@ -172,4 +184,38 @@ export class WebAudioLayer implements AudioLayer {
172184
}
173185
return this.context.state as AudioContextState
174186
}
187+
188+
// Recording methods
189+
enableRecording(): void {
190+
if (!this.context || !this.masterGain || this.isRecordingEnabled) {
191+
return
192+
}
193+
194+
// Create recording destination and connect master gain to it
195+
this.recordingDestination = this.context.createMediaStreamDestination()
196+
this.masterGain.connect(this.recordingDestination)
197+
this.isRecordingEnabled = true
198+
}
199+
200+
disableRecording(): void {
201+
if (
202+
!this.masterGain ||
203+
!this.recordingDestination ||
204+
!this.isRecordingEnabled
205+
) {
206+
return
207+
}
208+
209+
// Disconnect recording destination
210+
this.masterGain.disconnect(this.recordingDestination)
211+
this.recordingDestination = null
212+
this.isRecordingEnabled = false
213+
}
214+
215+
getRecordingStream(): MediaStream | null {
216+
if (!this.recordingDestination) {
217+
return null
218+
}
219+
return this.recordingDestination.stream
220+
}
175221
}

0 commit comments

Comments
 (0)