Skip to content

Commit 877fc54

Browse files
committed
Add tests to sound bundle
1 parent 9dc79fc commit 877fc54

File tree

6 files changed

+108
-79
lines changed

6 files changed

+108
-79
lines changed

src/bundles/sound/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
"devDependencies": {
1010
"@sourceacademy/modules-buildtools": "workspace:^",
1111
"@sourceacademy/modules-lib": "workspace:^",
12-
"playwright": "^1.54.1",
13-
"typescript": "^5.8.2",
14-
"vitest": "^3.2.3"
12+
"typescript": "^5.8.2"
1513
},
1614
"type": "module",
1715
"exports": {

src/bundles/sound/src/__tests__/recording.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// @vitest-environment jsdom
21
import { stringify } from 'js-slang/dist/utils/stringify';
32
import {
43
afterEach,
@@ -25,13 +24,26 @@ vi.spyOn(global, 'navigator', 'get').mockReturnValue({
2524
} as any);
2625

2726
Object.defineProperty(global, 'AudioContext', {
28-
value: () => mockAudioContext
27+
value: () => {},
28+
writable: true
2929
});
3030

3131
Object.defineProperty(global, 'MediaRecorder', {
32-
value: () => mockMediaRecorder
32+
value: () => {},
33+
writable: true
3334
});
3435

36+
Object.defineProperty(global, 'URL', {
37+
value: class {
38+
static createObjectURL() {
39+
return '';
40+
}
41+
}
42+
});
43+
44+
vi.spyOn(global, 'AudioContext').mockReturnValue(mockAudioContext as any);
45+
vi.spyOn(global, 'MediaRecorder').mockReturnValue(mockMediaRecorder as any);
46+
3547
beforeEach(() => {
3648
funcs.globalVars.recordedSound = null;
3749
funcs.globalVars.stream = null;

src/bundles/sound/src/__tests__/sound.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { afterEach, beforeEach, describe, expect, it, test } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
22
import * as funcs from '../functions';
33
import type { Sound, Wave } from '../types';
44
import { mockAudioContext } from './utils';
55

66
Object.defineProperty(global, 'AudioContext', {
7-
value: () => mockAudioContext
7+
value: () => {},
8+
writable: true,
89
});
910

11+
vi.spyOn(global, 'AudioContext').mockReturnValue(mockAudioContext as any);
12+
1013
describe(funcs.make_sound, () => {
1114
it('Should error gracefully when duration is negative', () => {
1215
expect(() => funcs.make_sound(() => 0, -1))
@@ -48,8 +51,7 @@ describe('Concurrent playback functions', () => {
4851
expect(() => funcs.play(0 as any)).toThrow('play is expecting sound, but encountered 0');
4952
});
5053

51-
test.only('Concurrently playing two sounds should error', () => {
52-
console.log(AudioContext);
54+
test('Concurrently playing two sounds should error', () => {
5355
const sound = funcs.silence_sound(10);
5456
expect(() => funcs.play(sound)).not.toThrow();
5557
expect(() => funcs.play(sound)).toThrowError('play: Previous sound still playing');

src/bundles/sound/src/functions.ts

Lines changed: 85 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ import type {
2424
// Global Constants and Variables
2525
const FS: number = 44100; // Output sample rate
2626
const fourier_expansion_level: number = 5; // fourier expansion level
27+
/**
28+
* duration of recording signal in milliseconds
29+
*/
30+
const recording_signal_ms = 100;
31+
/**
32+
* duration of pause after "record" before recording signal is played
33+
*/
34+
const pre_recording_signal_pause_ms = 200;
2735

2836
const audioPlayed: AudioPlayed[] = [];
29-
context.moduleContexts.sound.state = {
30-
audioPlayed
31-
};
37+
context.moduleContexts.sound.state = { audioPlayed };
3238

3339
interface BundleGlobalVars {
3440
/**
@@ -58,7 +64,10 @@ export const globalVars: BundleGlobalVars = {
5864
audioplayer: null
5965
};
6066

61-
// Instantiates new audio context
67+
/**
68+
* Returns the current AudioContext in use for the bundle. If
69+
* none, initializes a new context and returns it.
70+
*/
6271
function getAudioContext() {
6372
if (!globalVars.audioplayer) {
6473
globalVars.audioplayer = new AudioContext();
@@ -79,8 +88,10 @@ function linear_decay(decay_period: number): (t: number) => number {
7988
// ---------------------------------------------
8089
// Microphone Functionality
8190
// ---------------------------------------------
82-
// check_permission is called whenever we try
83-
// to record a sound
91+
/**
92+
* Determine if the user has already provided permission to use the
93+
* microphone and return the provided MediaStream if they have.
94+
*/
8495
function check_permission() {
8596
if (globalVars.stream === null) {
8697
throw new Error('Call init_record(); to obtain permission to use microphone');
@@ -93,39 +104,41 @@ function check_permission() {
93104
return globalVars.stream;
94105
}
95106

107+
/**
108+
* Set up the provided MediaRecorder and begin the recording
109+
* process.
110+
*/
96111
function start_recording(mediaRecorder: MediaRecorder) {
97112
const data: Blob[] = [];
98113
mediaRecorder.ondataavailable = (e) => e.data.size && data.push(e.data);
99114
mediaRecorder.start();
100115
mediaRecorder.onstop = () => process(data);
101116
}
102117

103-
// duration of recording signal in milliseconds
104-
const recording_signal_ms = 100;
105-
106-
// duration of pause after "run" before recording signal is played
107-
const pre_recording_signal_pause_ms = 200;
108-
109118
function play_recording_signal() {
110119
play(sine_sound(1200, recording_signal_ms / 1000));
111120
}
112121

122+
/**
123+
* Converts the data received from the MediaRecorder into an AudioBuffer.
124+
*/
113125
function process(data: Blob[]) {
114126
const audioContext = new AudioContext();
115127
const blob = new Blob(data);
116-
117-
convertToArrayBuffer(blob)
118-
.then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
119-
.then(save);
120-
}
121-
122-
// Converts input microphone sound (blob) into array format.
123-
async function convertToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
124128
const url = URL.createObjectURL(blob);
125-
const response = await fetch(url);
126-
return response.arrayBuffer();
129+
fetch(url)
130+
.then(async response => {
131+
const arrayBuffer = await response.arrayBuffer();
132+
const decodedBuffer = await audioContext.decodeAudioData(arrayBuffer);
133+
save(decodedBuffer);
134+
});
127135
}
128136

137+
/**
138+
* Converts the data stored in the provided AudioBuffer, converts it
139+
* into a Sound and then stores it into the `globalVars.recordedSound`
140+
* variable.
141+
*/
129142
function save(audioBuffer: AudioBuffer) {
130143
const array = audioBuffer.getChannelData(0);
131144
const duration = array.length / FS;
@@ -140,29 +153,6 @@ function save(audioBuffer: AudioBuffer) {
140153
}, duration);
141154
}
142155

143-
/**
144-
* Throws an exception if duration is not a number or if
145-
* number is negative
146-
*/
147-
function validateDuration(func_name: string, duration: unknown): asserts duration is number {
148-
if (typeof duration !== 'number') {
149-
throw new Error(`${func_name} expects a number for duration, got ${duration}`);
150-
}
151-
152-
if (duration < 0) {
153-
throw new Error(`${func_name}: Sound duration must be greater than or equal to 0`);
154-
}
155-
}
156-
157-
/**
158-
* Throws an exception if wave is not a function
159-
*/
160-
function validateWave(func_name: string, wave: unknown): asserts wave is Wave {
161-
if (typeof wave !== 'function') {
162-
throw new Error(`${func_name} expects a wave, got ${wave}`);
163-
}
164-
}
165-
166156
/**
167157
* Initialize recording by obtaining permission
168158
* to use the default device microphone
@@ -183,8 +173,15 @@ export function init_record(): string {
183173
/**
184174
* Records a sound until the returned stop function is called.
185175
* Takes a buffer duration (in seconds) as argument, and
186-
* returns a nullary stop. A call to the stop function returns
187-
* a Sound promise:
176+
* returns a nullary stop. A call to the stop function returns a Sound promise.
177+
*
178+
* How the function behaves in detail:
179+
* 1. `record` is called.
180+
* 2. The function waits for the given buffer duration.
181+
* 3. The recording signal is played.
182+
* 4. Recording begins when the recording signal finishes.
183+
* 5. Recording stops when the returned stop function is called.
184+
*
188185
* @example
189186
* ```ts
190187
* init_record();
@@ -196,6 +193,10 @@ export function init_record(): string {
196193
* @param buffer - pause before recording, in seconds
197194
*/
198195
export function record(buffer: number): () => SoundPromise {
196+
if (typeof buffer !== 'number' || buffer < 0) {
197+
throw new Error(`${record.name}: Expected a positive number for buffer, got ${buffer}`);
198+
}
199+
199200
if (globalVars.isPlaying) {
200201
throw new Error(`${record.name}: Cannot record while another sound is playing!`);
201202
}
@@ -229,17 +230,24 @@ export function record(buffer: number): () => SoundPromise {
229230
}
230231

231232
/**
232-
* Records a sound of given <CODE>duration</CODE> in seconds, after
233-
* a <CODE>buffer</CODE> also in seconds, and
234-
* returns a Sound promise: a nullary function
235-
* that returns a Sound. Example: <PRE><CODE>init_record();
236-
* const promise = record_for(2, 0.5);
237-
* // In next query, you can play the promised Sound, by
238-
* // applying the promise:
239-
* play(promise());</CODE></PRE>
233+
* Records a sound of a given duration. Returns a Sound promise.
234+
*
235+
* How the function behaves in detail:
236+
* 1. `record_for` is called.
237+
* 2. The function waits for the given buffer duration.
238+
* 3. The recording signal is played.
239+
* 4. Recording begins when the recording signal finishes.
240+
* 5. The recording signal plays to signal the end after the given duration.
241+
*
242+
* @example
243+
* ```
244+
* init_record();
245+
* const promise = record_for(2, 0.5); // begin recording after 0.5s for 2s
246+
* const sound = promise(); // retrieve the recorded sound
247+
* play(sound); // and do whatever with it
248+
* ```
240249
* @param duration duration in seconds
241250
* @param buffer pause before recording, in seconds
242-
* @return <CODE>promise</CODE>: nullary function which returns recorded Sound
243251
*/
244252
export function record_for(duration: number, buffer: number): SoundPromise {
245253
if (globalVars.isPlaying) {
@@ -278,15 +286,28 @@ export function record_for(duration: number, buffer: number): SoundPromise {
278286
return promise;
279287
}
280288

281-
// =============================================================================
282-
// Module's Exposed Functions
283-
//
284-
// This file only includes the implementation and documentation of exposed
285-
// functions of the module. For private functions dealing with the browser's
286-
// graphics library context, see './webGL_curves.ts'.
287-
// =============================================================================
289+
/**
290+
* Throws an exception if duration is not a number or if
291+
* number is negative
292+
*/
293+
function validateDuration(func_name: string, duration: unknown): asserts duration is number {
294+
if (typeof duration !== 'number') {
295+
throw new Error(`${func_name} expects a number for duration, got ${duration}`);
296+
}
297+
298+
if (duration < 0) {
299+
throw new Error(`${func_name}: Sound duration must be greater than or equal to 0`);
300+
}
301+
}
288302

289-
// Core functions
303+
/**
304+
* Throws an exception if wave is not a function
305+
*/
306+
function validateWave(func_name: string, wave: unknown): asserts wave is Wave {
307+
if (typeof wave !== 'function') {
308+
throw new Error(`${func_name} expects a wave, got ${wave}`);
309+
}
310+
}
290311

291312
/**
292313
* Makes a Sound with given wave function and duration.

src/bundles/sound/vitest.config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ export default defineConfig({
99
root: import.meta.dirname,
1010
browser: {
1111
enabled: false,
12-
provider: 'playwright',
13-
instances: [{
14-
screenshotFailures: false,
15-
browser: 'chromium',
16-
}]
1712
}
1813
}
1914
});

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3530,6 +3530,7 @@ __metadata:
35303530
dependencies:
35313531
"@sourceacademy/bundle-midi": "workspace:^"
35323532
"@sourceacademy/modules-buildtools": "workspace:^"
3533+
"@sourceacademy/modules-lib": "workspace:^"
35333534
js-slang: "npm:^1.0.81"
35343535
typescript: "npm:^5.8.2"
35353536
languageName: unknown

0 commit comments

Comments
 (0)