Skip to content

Commit 9b87e66

Browse files
committed
[v1.5.0] dynamic client-side multi-channel audio panning
1 parent 949e409 commit 9b87e66

File tree

7 files changed

+72
-187
lines changed

7 files changed

+72
-187
lines changed

README.md

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ Facet runs on MacOS, Linux, and Windows.
66

77
## Installation and getting started
88

9-
1. Download and install Node.js (must be v14 or greater) and npm: https://www.npmjs.com/get-npm
10-
2. Download and install SoX as a command line tool (the latest version is 14.4.2): http://sox.sourceforge.net/ If using homebrew: `brew install sox` should work. If running on Windows: you need to modify your Path environment variable so that SoX can be run from the command line. Ultimately you need to be able to run the command `sox` from the command line and verify that it's installed.
11-
3. Download or clone the Facet repository. If you download it, make sure that the repository name is exactly `facet` and NOT `facet-main`.
12-
4. In a terminal, navigate to the root of the Facet repository, and run `npm install`.
13-
5. After the previous command completes, run `npm run facet`. This will start the servers that run in the background for generating and patterns and keeping time. If running on Windows: Windows has a firewall by default for local connections (on the same private network), and it needs to be disabled, or you can manually allow the connection via the confirmation dialog from the Windows firewall system when starting up the servers.
14-
6. In a browser tab (Firefox or Chrome work best), navigate to http://localhost:1124. This is the browser-based code editor which can also handle stereo audio playback.
15-
7. Copy this command into the code editor in the browser: `$('test').sine(100).play();` Move your cursor so it's on the line. Hit `[ctrl + enter]` to run the command. The code editor application will always briefly highlights to illustrate what command(s) ran. You should hear a sine wave playing through your browser tab. Hit `[ctrl + .]` or `[ctrl + /]` (Windows) to stop.
9+
1. Download and install Node.js (must be v14 or greater) and npm: https://www.npmjs.com/get-np
10+
2. Download or clone the Facet repository.
11+
3. In a terminal, navigate to the root of the Facet repository, and run `npm install`.
12+
4. After the previous command completes, run `npm run facet`. This will start the servers that run in the background for generating and patterns and keeping time. If running on Windows: Windows has a firewall by default for local connections (on the same private network), and it needs to be disabled, or you can manually allow the connection via the confirmation dialog from the Windows firewall system when starting up the servers.
13+
5. In a browser tab (Firefox or Chrome work best), navigate to http://localhost:1124. This is the browser-based code editor which can also handle stereo audio playback.
14+
6. Copy this command into the code editor in the browser: `$('test').sine(100).play();` Move your cursor so it's on the line. Hit `[ctrl + enter]` to run the command. The code editor application will always briefly highlights to illustrate what command(s) ran. You should hear a sine wave playing through your browser tab. Hit `[ctrl + .]` or `[ctrl + /]` (Windows) to stop.
1615

1716
## Facet commands
1817

@@ -134,14 +133,12 @@ Facet can synthesize and orchestrate the playback of multiple FacetPatterns simu
134133
- `$('example').randsamp('808').play(0.5); // plays once at middle point`
135134
- `$('example').randsamp('808').play(_.noise(4)); // plays once at 4 random positions`
136135
---
137-
- **pan** ( _PanningFacetPattern_, _pan_mode_ = 0 )
136+
- **pan** ( _PanningFacetPattern_ 0 )
138137
- dynamically moves the FacetPattern between however many channels are specified in a seperate `.channels()` call. Without a call to `.channels()`, it will default to spatially positioning the FacetPattern between channels 1 and 2.
139-
- the values in `PanningFacetPattern` should be between -1 and 1. Values beyond that will be clipped to the -1 - 1 range. A value of -1 will hard-pan the sound to the first active channel that is set via a `.channels()` call (or defaulting to stereo). A value of 1 will hard-pan the sound to the last active channel. Values between -1 and 1 will crossfade between all the specified active channels.
140-
- the default `pan_mode` of 0 means that the panning moves smoothly between channels, e.g., channels adjacent to the selected full-volume channel will have some signal bleeding into them. Switching the `pan_mode` to 1 makes the panning work in a discrete manner, where only one channel has a signal in it at any given time, and there is no bleed between channels.
138+
- the values in `PanningFacetPattern` should be between 0 and 1. Values beyond that will be clipped to the 0 - 1 range. A value of 0 will hard-pan the sound to the first active channel that is set via a `.channels()` call (or defaulting to stereo). A value of 1 will hard-pan the sound to the last active channel. Values between 0 and 1 will crossfade between all the specified active channels.
141139
- example:
142-
- `$('example').noise(n1).times(_.ramp(1,0,n1)).pan(_.sine(1,n1)).play(); // no channels are specified; defaults to stereo panning`
143-
- `$('example').noise(n1).times(_.ramp(1,0,n1)).channels([1,2,4]).pan(_.sine(1,n1)).play(); // pans the noise smoothly around channels 1, 2, and 4`
144-
- `$('example').noise(n1).times(_.ramp(1,0,n1)).channels([1,2,4]).pan(_.sine(1,n1),1).play(); // hard-pans the noise discretely between channels 1, 2, and 4`
140+
- `$('example').noise(n1).times(_.ramp(1,0,n1)).pan(_.sine(1,n1).scale(0,1)).play(); // no channels are specified; defaults to stereo panning`
141+
- `$('example').noise(n1).times(_.ramp(1,0,n1)).channels([1,2,4]).pan(_.sine(1,n1).scale(0,1)).play(); // pans the noise smoothly around channels 1, 2, and 4`
145142
---
146143
- **channel** ( _channels_ )
147144
- Facet ultimately creates wav files that can have any number of channels. The `.channel()` method (and equivalent `channels()` method) allow you to route the output of a FacetPattern onto the specified channel(s) in the `channels` input array. **NOTE:** CPU will also increase as the total number of channels increases.
@@ -151,20 +148,10 @@ Facet can synthesize and orchestrate the playback of multiple FacetPatterns simu
151148
- `$('example').randsamp('808').channel(_.from([9,10,11,12,13,14,15,16]).shuffle().reduce(ri(1,8))).play(); // play on a random number of channels from 9-16`
152149
---
153150
- **saveas** ( _filename_ )
154-
- creates a new wav file in the `samples/` directory or a sub-directory containing the FacetPattern. If the directory doesn't exist, it will be created.
155-
- if a file has been created with multiple channels via `.channels()` or with its audio panned between multiple channels via `.pan()`, the saved wav file will have that many channels.
151+
- saves a monophonic, 32-bit depth wav file in the `samples/` directory or a sub-directory, containing the FacetPattern. If the directory doesn't exist, it will be created.
156152
- __Note__: this example uses MacOS / Linux file paths with forward slashes (e.g. `my/path/here`). For Windows, you will need to use back slashes (e.g `my\path\here`)
157153
- example:
158154
- `$('example').iter(6,()=>{this.append(_.sine(ri(1,40))).saveas('/myNoiseStuff/' + Date.now()`)}); // creates 6 wav files in the myNoiseStuff directory. Each filename is the UNIX timestamp to preserve order.
159-
---
160-
- **stitchdir** ( _dir_, _samplesBetweenEachFile_, _saved_filename_ = 'stitched', _num_channels_ = 1 )
161-
- stitches together all the wav files in the supplied `dir` directory, in alphabetical order, creating a new wav file in the `samples/` directory or a sub-directory, as specified in `saved_filename`. If the directory doesn't exist, it will be created.
162-
- the `samplesBetweenEachFile` argument can be a single number or a FacetPattern. This value specifies the exact number of samples between each file in the output file. If it's a FacetPattern, its values will be continuously cycled through while stitching together all the files in the directory.
163-
- all files in the directory should have the same number of channels. The stitched wav file will have `num_channels` channels (default = 1).
164-
- __Note__: this process can take minutes if there are a lot of wavs, so by default any time this method is called, it will be called once and only once.
165-
- __Note__: this example uses MacOS / Linux file paths with forward slashes (e.g. `my/path/here`). For Windows, you will need to use back slashes (e.g `my\path\here`)
166-
- example:
167-
- `$('example').stitchdir('mysamples',n1,'myNewStitchedFile'); // stitch together all the wavs in samples/mysamples, with a whole note between each file, creating a new file called MyNewStitchedFile.wav`
168155
- **stop** ( )
169156
- stops the command from regenerating and playing back in future loops.
170157
- any time a `.stop()` is found in a command, the entire command will be skipped and not executed. This helps to preserve CPU.

js/FacetPattern.js

Lines changed: 7 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class FacetPattern {
3131
this.current_slice_number = 0;
3232
this.current_total_slices = 0;
3333
this.current_total_iterations = 0;
34-
this.dacs = '1 1';
34+
this.dacs = [1,2];
3535
this.data = [];
3636
this.do_not_regenerate = false;
3737
this.env = this.getEnv();
@@ -43,7 +43,6 @@ class FacetPattern {
4343
this.original_data = [];
4444
this.notes = [];
4545
this.pan_data = false;
46-
this.pan_mode = 0;
4746
this.play_once = false;
4847
this.original_command = '';
4948
this.over_n = 1;
@@ -404,56 +403,6 @@ class FacetPattern {
404403
return this;
405404
}
406405

407-
stitchdir (dir, samplesBetweenEachFile = this.getWholeNoteNumSamples(), saved_filename = 'stitched', num_channels = 1) {
408-
if ( this.isFacetPattern(samplesBetweenEachFile) ) {
409-
samplesBetweenEachFile = samplesBetweenEachFile.data;
410-
}
411-
else {
412-
samplesBetweenEachFile = [Number(samplesBetweenEachFile)];
413-
}
414-
415-
// these are safeguards so this command runs when and only when the user initializes it, rather than each loop
416-
this.play_once = true;
417-
this.do_not_regenerate = true;
418-
419-
let stitchDir = dir;
420-
if (!dir) {
421-
stitchDir = `${process.cwd()}${cross_platform_slash}samples`;
422-
}
423-
else {
424-
stitchDir = `${process.cwd()}${cross_platform_slash}samples${cross_platform_slash}${dir}`;
425-
}
426-
427-
let wav_files_to_stitch = [];
428-
let original_files = fs.readdirSync(stitchDir);
429-
original_files.filter(file => path.extname(file) === '.wav')
430-
.sort()
431-
.forEach(file => {
432-
if (path.extname(file) == ".wav") {
433-
wav_files_to_stitch.push(file);
434-
}
435-
});
436-
437-
let multi_channel_sox_cmd = 'sox --combine merge';
438-
for (var n = 0; n < num_channels; n++) {
439-
let silence_samples_to_add = 0;
440-
let iters = 0;
441-
let out_fp = new FacetPattern();
442-
for (var i = 0; i < wav_files_to_stitch.length; i++) {
443-
let file = wav_files_to_stitch[i];
444-
let next_fp_to_add = new FacetPattern().sample(`${dir}${cross_platform_slash}${file}`,n).prepend(new FacetPattern().silence(silence_samples_to_add));
445-
out_fp.sup(next_fp_to_add,0);
446-
silence_samples_to_add += samplesBetweenEachFile[iters%samplesBetweenEachFile.length]
447-
iters++;
448-
}
449-
out_fp.savemono(`${dir}${cross_platform_slash}${saved_filename}-ch${n}`);
450-
multi_channel_sox_cmd += ` ${stitchDir}${cross_platform_slash}${saved_filename}-ch${n}.wav`
451-
}
452-
multi_channel_sox_cmd += ` ${stitchDir}${cross_platform_slash}${saved_filename}-out.wav`;
453-
exec(`${multi_channel_sox_cmd}`, (error, stdout, stderr) => {});
454-
return this;
455-
}
456-
457406
sample (file_name, channel_index = 0) {
458407
if ( !file_name.includes('.wav') ) {
459408
file_name = `${file_name}.wav`;
@@ -2847,23 +2796,17 @@ scaleLT1 (outMin, outMax, exponent = 1) {
28472796
}
28482797

28492798
channels (chans) {
2850-
this.dacs = '';
2799+
// convert to array and set as this.dacs
28512800
if ( typeof chans == 'number' ) {
28522801
chans = [chans];
28532802
}
28542803
else if ( this.isFacetPattern(chans) ) {
28552804
chans = chans.data;
28562805
}
2857-
for (var i = 0; i < Math.max(...chans); i++) {
2858-
if ( chans.includes(i+1) ) {
2859-
this.dacs += '1 ';
2860-
}
2861-
else {
2862-
this.dacs += '0 ';
2863-
}
2806+
this.dacs = chans;
2807+
if (this.dacs.length == 0 ) {
2808+
throw `channels cannot be empty`
28642809
}
2865-
// remove last space
2866-
this.dacs = this.dacs.slice(0, -1);
28672810
return this;
28682811
}
28692812

@@ -3203,13 +3146,12 @@ rechunk (numChunks, probability = 1) {
32033146
return this;
32043147
}
32053148

3206-
pan ( pan_amt, mode = 0 ) {
3149+
pan ( pan_amt ) {
32073150
if ( typeof pan_amt == 'number' || Array.isArray(pan_amt) === true ) {
32083151
pan_amt = new FacetPattern().from(pan_amt);
32093152
}
3210-
pan_amt.clip(-1,1);
3153+
pan_amt.clip(0,1);
32113154
this.pan_data = pan_amt.data;
3212-
this.pan_mode = mode;
32133155
return this;
32143156
}
32153157

js/editor.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,9 @@ let pitchShifts = {};
380380
let lastPlayedTimes = {};
381381
let ac;
382382
ac = new AudioContext();
383+
ac.destination.channelCount = ac.destination.maxChannelCount;
384+
ac.destination.channelCountMode = "explicit";
385+
ac.destination.channelInterpretation = "discrete";
383386

384387
// connect to the server
385388
const socket = io.connect(`http://${configSettings.HOST}:3000`, {
@@ -413,18 +416,44 @@ socket.on('bpm', (bpm) => {
413416
socket.on('play', (data) => {
414417
let voice_to_play = data.voice;
415418
let pitch = data.pitch;
419+
let channels = data.channels;
420+
let pan_data = data.pan_data;
416421
pitchShifts[voice_to_play] = pitch;
417422
if ( browser_sound_output === true ) {
418423
// check if the voice is loaded
419424
if (voices[voice_to_play]) {
420-
let source = ac.createBufferSource();
421-
source.buffer = voices[voice_to_play].buffer;
422-
let current_bpm = $('#bpm').val();
423-
source.playbackRate.value = (current_bpm / voices[voice_to_play].bpm) * pitch;
424-
source.connect(ac.destination);
425-
source.start();
426-
sources[voice_to_play] = source;
427-
lastPlayedTimes[voice_to_play] = Date.now();
425+
let merger = ac.createChannelMerger(ac.destination.channelCount);
426+
channels.forEach((channel, index) => {
427+
let source = ac.createBufferSource();
428+
source.buffer = voices[voice_to_play].buffer;
429+
let current_bpm = $('#bpm').val();
430+
source.playbackRate.value = (current_bpm / voices[voice_to_play].bpm) * pitch;
431+
// create a gain node for each channel
432+
let gainNode = ac.createGain();
433+
source.connect(gainNode);
434+
gainNode.connect(merger, 0, channel - 1);
435+
436+
if (pan_data === false || channels.length === 1) {
437+
// if pan_data is false or there is only one channel, set the gain value to 1 for all channels
438+
gainNode.gain.value = 1;
439+
} else {
440+
// schedule changes in the gain value based on pan_data
441+
let durationPerValue = source.buffer.duration / pan_data.length;
442+
pan_data.forEach((panValue, i) => {
443+
let time = i * durationPerValue;
444+
// calculate the normalized channel index
445+
let normalizedIndex = index / (channels.length - 1);
446+
// only interpolate the gain for the two channels closest to the pan_data value - otherwise, gain = 0
447+
let gainValue = Math.abs(normalizedIndex - panValue) <= 1 / (channels.length - 1) ? 1 - Math.abs(normalizedIndex - panValue) : 0;
448+
gainNode.gain.setValueAtTime(gainValue, ac.currentTime + time);
449+
});
450+
}
451+
452+
source.start();
453+
sources[voice_to_play] = source;
454+
lastPlayedTimes[voice_to_play] = Date.now();
455+
});
456+
merger.connect(ac.destination);
428457
} else {
429458
// voice is not loaded yet
430459
}

0 commit comments

Comments
 (0)