A sketch to make an image filter similar to the image filter in AudioSculpt

Dear users,

The following is a simple sketch to mimic the image filter in AudioSculpt:

( 
s.waitForBoot {
    var length = 5; // seconds 
    var image = Image.new(
		SCDoc.helpSourceDir +/+ "images" +/+
		// "icon.supercollider.png"
		// "vline.png"
		 "node.png"
		// "lastnode.png"
		// "flowers2.jpg"
		// "duck_alpha.png"
		// "vduck2.jpg"
		// "Swamp.png"
		);
    var lastFreqIndex = image.height - 1;
    var lastTimeDomainIndex = image.width - 1;
    var numSpectrumsInTimeDomain = (0..lastTimeDomainIndex).collect { |indexTimeDomain|
        (lastFreqIndex..0).collect { |indexFreq|
            var pixelColor = Image.pixelToColor(image.getPixel(indexTimeDomain, indexFreq));
            [pixelColor.red, pixelColor.green, pixelColor.blue, pixelColor.alpha]
        }
    };
    var freqsSize = numSpectrumsInTimeDomain[0].size;
    var freqs = (1..freqsSize).linexp(1, freqsSize, 20, s.sampleRate / 2);
    var timeUntilNext = length / (lastTimeDomainIndex + 1);
    
    var synth = { |amp, lagTime|
        var source = WhiteNoise.ar;
        Resonz.ar(
            source,
            freqs,
            0.004,
            (amp * \amps.kr(0 ! freqsSize)).lag(lagTime) 
        ).sum/numSpectrumsInTimeDomain.size
    }.play(args:[amp: 0, lagTime: timeUntilNext]);
    
	image.plot;
	
    s.sync;
    
    numSpectrumsInTimeDomain.size.do { |index|
        var thisSpectrumAmps = numSpectrumsInTimeDomain[index].collect{ |thisRGBA|
            thisRGBA[0..2].sum / 3 * thisRGBA[3]
        };
        synth.set(\amp, 10, \amps, thisSpectrumAmps);
        timeUntilNext.wait
    };
    
    synth.free
}
)

Is there another way to do this? It would be nice if I could control FFT filters or other things instead of Resonz, BPF or DynKlank.

3 Likes

That’s cool!
Here’s a version with Sines using DynKlang, which sounds a bit different:

(
s.waitForBoot {
    var length = 5; // seconds
    var image = Image.new(
		SCDoc.helpSourceDir +/+ "images" +/+
		// "icon.supercollider.png"
		// "vline.png"
		// "node.png"
		// "lastnode.png"
		// "flowers2.jpg"
		"duck_alpha.png"
		// "vduck2.jpg"
		// "Swamp.png"
		);
    var lastFreqIndex = image.height - 1;
    var lastTimeDomainIndex = image.width - 1;
    var numSpectrumsInTimeDomain = (0..lastTimeDomainIndex).collect { |indexTimeDomain|
        (lastFreqIndex..0).collect { |indexFreq|
            var pixelColor = Image.pixelToColor(image.getPixel(indexTimeDomain, indexFreq));
            [pixelColor.red, pixelColor.green, pixelColor.blue, pixelColor.alpha]
        }
    };
    var freqsSize = numSpectrumsInTimeDomain[0].size;
    var freqs = (1..freqsSize).linexp(1, freqsSize, 20, s.sampleRate / 2);
    var timeUntilNext = length / (lastTimeDomainIndex + 1);

    var synth = { |amp, lagTime|
		0.1 * DynKlang.ar(`[
            freqs,
			(amp * \amps.kr(0 ! freqsSize)).lag(lagTime),
			nil
		])
		/ numSpectrumsInTimeDomain.size
    }.play(args:[amp: 0, lagTime: timeUntilNext]);

	image.plot(bounds: Rect(60, 100, 500, 500));

    s.sync;

    numSpectrumsInTimeDomain.size.do { |index|
        var thisSpectrum = numSpectrumsInTimeDomain[index].collect{ |thisSpectrumAmps|
            thisSpectrumAmps[0..2].sum / 3 * thisSpectrumAmps[3]
        };
        synth.set(\amp, 10, \amps, thisSpectrum);
        timeUntilNext.wait
    };

    synth.free
}
)
4 Likes

Just for fun I made a version that draws random shapes onto an image:

(
s.waitForBoot {
	var length = 10; // seconds
	var imageWidth = 256;
	var addTriangleFunc = {
		var point = Point(imageWidth.rand, imageWidth.rand);
		Pen.color = Color.grey((0.25, 0.3..0.75).choose, (0.25, 0.3..0.75).choose);
		Pen.moveTo(Point(point.x + 50.rand2, point.y + 50.rand2));
		Pen.lineTo(Point(point.x + 50.rand2, point.y + 50.rand2));
		Pen.lineTo(Point(point.x + 50.rand2, point.y + 50.rand2));
		Pen.perform([\stroke, \fill].choose);
	};
	var addOvalFunc = {
		Pen.fillOval(Rect.aboutPoint(
			Point(imageWidth.rand, imageWidth.rand),
			5.rrand(50),
			5.rrand(50)
		));
	};

	var image = Image.new(imageWidth, imageWidth)
	.draw({ arg image;
		// black background
		Pen.color = Color.black;
		Pen.fillRect(Rect(0, 0, image.width, image.height));
		// triangles
		40.do {
			[addTriangleFunc, addOvalFunc].choose.value;
		};
		// dust
		300.do {
			Pen.color = Color.grey([0.5, 0.7, 0.8, 1].choose, [0.25, 0.5, 0.75, 1].choose);
			Pen.fillRect(Rect(
				image.width.rand.blend(imageWidth * 0.5, 1.0.rand),
				image.height.rand.blend(imageWidth * 0.5, 1.0.rand),
				1, 1));
		};

	});
	var lastFreqIndex = image.height - 1;
	var lastTimeDomainIndex = image.width - 1;
	var numSpectrumsInTimeDomain = (0..lastTimeDomainIndex).collect { |indexTimeDomain|
		(lastFreqIndex..0).collect { |indexFreq|
			var pixelColor = Image.pixelToColor(image.getPixel(indexTimeDomain, indexFreq));
			[pixelColor.red, pixelColor.green, pixelColor.blue, pixelColor.alpha]
		}
	};
	var freqsSize = numSpectrumsInTimeDomain[0].size;
	var freqs = (1..freqsSize).linexp(1, freqsSize, 20, s.sampleRate / 2);
	var timeUntilNext = length / (lastTimeDomainIndex + 1);

	var synth = { |amp, lagTime|
		0.05 * DynKlang.ar(`[
			freqs,
			(amp * \amps.kr(0 ! freqsSize)).lag(lagTime),
			nil
		]) ! 2
		/ numSpectrumsInTimeDomain.size
	}.play(args:[amp: 0, lagTime: timeUntilNext]);

	w = image.plot(bounds: Rect(60, 100, 500, 500), freeOnClose:true);

	s.sync;

	numSpectrumsInTimeDomain.size.do { |index|
		var thisSpectrum = numSpectrumsInTimeDomain[index].collect{ |thisSpectrumAmps|
			thisSpectrumAmps[0..2].sum / 3 * thisSpectrumAmps[3]
		};
		synth.set(\amp, 10, \amps, thisSpectrum);
		timeUntilNext.wait
	};

	synth.free;
	1.wait;
	w.close;
}
)
3 Likes

Thanks for sharing!
Yes, additive synthesis is one approach!

I was only thinking about subtractive synthesis, influenced by AudioSculpt. However, I feel that my original code is missing something (not due to amplitude) compared to the sound of AudioSculpt. If I remember correctly, AudioSculpt’s sound was richer. With GVerb I can achieve a richer sound, but I still miss AudioSculpt a little.

However, your additive synthesis is excellent and I have no complaints!

The second example is also fantastic. This template could be used to simplify the following two tasks

  • Sonification.
  • Simultaneous sound design and visualisation.

3 posts were split to a new topic: SC learning process: opinions, advice

For a subtractive approach, there’s also DynKlank.
I don’t know AudioSculpt, but for a different sound you could also try other types of noise as a source instead of WhiteNoise - e.g. PinkNoise, Dust or ClipNoise.

@sslew
I also think @TXMod is very competent in using SC and handling sound, and wanted to know how he learned SC. No problem. Thanks for asking instead of me!

@TXMod
In my first post I summed signals to a mono signal, but that was just a test.

Klank, Klang, DynKlank and DynKlang seem to use less system resources than sum (or other) signal arrays. However, they do sum signals on a channel. So I do not prefer them if they do not sum signals.

{ var n = 4; Klang.ar(`[220 * (1 .. n), 0.1, 0], 1, 0)/n }.play; // standard and efficient
{ var n = 4, src = SinOsc.ar(220 * (1 .. n), 0, 0.1/n); Splay.ar(src.scramble) }.play // flexible and interesting

What I want to know to my inital question is if there would be a way to dump the spectrum information driven by arrays of vertical pixels of specific time point of the image to FFT filter.

Has anyone ever seen a gui app made with sc where you can play with the FFT bins and do other graphic stuff to mess with the phase vocoder? I cant find anything out there. Im surprised.

something like GitHub - nhthn/canvas: A visual additive synthesizer ?

Here’s a little test I did a while ago of drawing onto a spectrogram with the mouse… it wasn’t fast enough for real time display so you only see what you’ve drawn after you’ve lifted the mouse button (play it by clicking to move the playhead and pressing space)

~winsize = 2048;
~hop = 0.15;
//~hop = 0.25;
~hop = (~hop * 2048 / 64).round * 64 / 2048;
~wintype = 1;
//~wintype = 0;

~brushHeight = 10;
~brushWidth = 10;

~inbuf.free;~inbuf = Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");
~buf.free;~buf = Buffer.alloc(s, ~inbuf.duration.calcPVRecSize(~winsize, ~hop, s.sampleRate));


(
// note the window type and overlaps... this is important for resynth parameters
SynthDef("pvrec", { |recbuf, inbuf, winsize = 2048, hop = 0.25, wintype = 1|
  var in, chain, bufnum;
  bufnum = LocalBuf.new(winsize, 1);
  Line.kr(1, 1, BufDur.kr(inbuf), doneAction: 2);
  in = PlayBuf.ar(1, inbuf, BufRateScale.kr(inbuf), loop: 0);
  chain = FFT(bufnum, in, hop, wintype);
  chain = PV_RecordBuf(chain, recbuf, 0, 1, 0, hop, wintype);
  // no ouput
}).add;
)

a = Synth("pvrec", [recbuf: ~buf, inbuf: ~inbuf, winsize: ~winsize, hop: ~hop, wintype: ~wintype]);


(
SynthDef("pvplay", { |out, buf = 1, offset = 0, rate = 1, winsize = 2048, hop = 0.25, wintype = 1|
  var in, chain, bufnum;
  bufnum = LocalBuf.new(winsize);
  chain = PV_PlayBuf(bufnum, buf, rate, offset, 0);
  Out.ar(out, IFFT(chain, 1).dup * hop);
}).add;
)

b = Synth("pvplay", [out: 0, buf: ~buf, rate: 0.75, winsize: ~winsize, hop: ~hop, wintype: ~wintype]);

// stop the synth
b.free;

// load to ~data array
~buf.loadToFloatArray(action: { |data| ~data = data.postln })

// to zero it out
~data = ~data[0..2] ++ (~data.size - 3).collect { 0 }
~data[0] = ~winsize;
~data[1] = ~hop;
~data[2] = ~wintype;

(
var sampleRate = s.sampleRate;
var numFrames = ((~data.size - 3) / ~winsize);

var width = numFrames.asInteger;
var height = (~winsize / 2).asInteger;
var colorFunc = { |mag| Color.gray(1, mag.log10.lincurve(-2, 2, 0, 1, curve: 0)) };
var scaleFunc = { |pixel| pixel.lincurve(0, height, 0, height, curve: 3) }; // curve factor for Y scaleing (0 = linear, 3 =~ mel scale)

~image = Image.new(width, height);

~magAt = { |frame, index|
  ~data[(frame * ~winsize) + (index * 2) + 4];
};
~setMag = { |frame, index, mag, phase|
  var i = (frame * ~winsize) + (index * 2) + 3;
  ~data[i + 1] = mag;
  if (phase.notNil) {
    ~data[i] = phase;
  };
};

~playrate = 0.1;
~synth = nil;
~playhead = 0;

if (~win.notNil) { ~win.close };
~win = Window("", Rect(0, 0, ~image.width, ~image.height)).front.background_(Color.white)
.view.keyDownAction_({ |v, char|
  if (char == $ ) {
    if (~synth.notNil) {
      ~synth.free;
      ~synth = nil;
      ~playrout.stop;
    } {
      ~synth = Synth("pvplay", [
        out: 0,
        buf: ~buf,
        offset: ~playhead / ~inbuf.duration * numFrames,
        rate: ~playrate,
        winsize: ~winsize,
        hop: ~hop,
        wintype: ~wintype
      ]);
      //~playhead = 0;
      ~playrout = fork {
        (~inbuf.duration / ~playrate * 30).do {
          ~playhead = ~playhead + (~playrate / 30);
          defer { ~playheadview.refresh };
          (1/30).wait;
        };
      };
    }
  };
});


~makeView = {
  var mouseDownPoint;
  var refreshImage = {
    ~image.setPixels(Int32Array.fill(width * height, { |i|
      var index = scaleFunc.(height - (i / width).floor).asInteger;
      var frame = i % width;
      var mag = ~magAt.(frame, index);
      Image.colorToPixel(colorFunc.(mag));
    }), Rect(0, 0, ~image.width, ~image.height));
  };
  refreshImage.();

  ~view.remove;
  ~view = UserView(~win, ~win.bounds).drawFunc_({
    Pen.use {
      Pen.scale(~view.bounds.width / ~image.width, ~view.bounds.height / ~image.height);
      Pen.drawImage(0@0, ~image)
    }
  }).resize_(5).background_(Color.black);

  ~playheadview = UserView(~win, ~win.bounds).drawFunc_({ |v|
  Pen.use {
  Pen.addRect(Rect(~playhead / ~inbuf.duration * v.bounds.width, 0, 2, v.bounds.height));
  Pen.color = Color.red;
  Pen.fill;
  }
  }).resize_(5)
  .mouseDownAction_({ |v, x, y|
    [x, y].postln;
    mouseDownPoint = x@y;
  })
  .mouseMoveAction_({ |v, x, y|
    /*
    var frame = x / v.bounds.width * numFrames;
    //var index = scaleFunc.((v.bounds.height - y) / v.bounds.height * height - 1).asInteger;
    var index = ((y.postln / v.bounds.height).lincurve(0.8, 0.0, 0.0, 0.8, 3, nil).postln * (~winsize / 2)).asInteger;
    */
    var index = scaleFunc.(height - y.linlin(0, v.bounds.height, 0, height)).asInteger;
    var frame = x.linlin(0, v.bounds.width, 0, width).asInteger;
    //var mag = ~magAt.(frame, index);
    var halfBW = (~brushWidth / 2).asInteger;
    var halfBH = (~brushHeight / 2).asInteger;
    ~brushWidth.do { |bwi|
      ~brushHeight.do { |bhi|
        var thisFrame = frame - halfBW + bwi;
        var thisIndex = index - halfBH + bhi;
        if (thisFrame.isPositive and: thisIndex.isPositive) {
          var distance = sqrt((thisFrame - frame).squared + (thisIndex - index).squared);
          var mag = ~magAt.(thisFrame, thisIndex);
          ~setMag.(thisFrame, thisIndex, mag + distance.lincurve(0, 10, 1, 0, -5).postln, rrand(0, 2pi));
        }
      }
    };

    //refreshImage.();
  })
  .mouseUpAction_({ |v, x, y|
    if (x@y == mouseDownPoint) {
      ~playhead = x / v.bounds.width * ~inbuf.duration;
    };
    ~buf.loadCollection(~data, action: { "loaded".postln });
    ~makeView.();
  });
};
~makeView.();
)

this generates the image from the PV buffer contents but it wouldn’t be too hard I think to do the reverse, generate the PV buffer from the image instead

@Eric_Sluyter

Do you know why the code runs so slow?

I got a considerable delay here, seconds.

I didn’t stop to read it, but maybe there is something going on there

It’s probably to do with drawing the image from the buffer contents…

var refreshImage = {
    ~image.setPixels(Int32Array.fill(width * height, { |i|
      var index = scaleFunc.(height - (i / width).floor).asInteger;
      var frame = i % width;
      var mag = ~magAt.(frame, index);
      Image.colorToPixel(colorFunc.(mag));
    }), Rect(0, 0, ~image.width, ~image.height));
  };
  refreshImage.();

Going the other way (filling the buffer based on the image) would be faster, I think

1 Like

Maybe dividing the function into different routines with smaller chunks would make sense?

But I think this is not much better either. There must be a way to do this.

I think there is something that is not quite right to take that long.

Some GUI master will help us, hopefully.

Thank you for this!
It is amazing,

I need more time and concentration to combine your code with mine.
One thing is using Lanczos interpolation to adjust the y-axis size to the spectrum bin size… It is not a big deal. It is the next step that confuses me a bit.
I will post when I make progress. However, I cannot devote more time to it now…

1 Like

Thanks @Eric_Sluyter for sharing this code.
I had a go at speeding it up by only updating the parts of the image that are drawn on.
In the version below, I also added some gui controls and more brush options.
One issue is that the Y axis must use linear scaling for this version to work (in your original code you could change the scaling curve but I took away that option).
Playing with this, I find it harder to create new compositions using this method because of the limitation with FFT that the frequencies are linearly distributed. This means that most of the image represents the highest octaves of the spectrum. This is very different to @prko’s code where the frequencies are evenly distributed between octaves (ie. exponential distribution).
Anyway, maybe it’s still useful for spectral editing so I’m sharing the new version here:

(
s.waitForBoot ({

	/* Optional  ====
	// select a mono sound file to open
	FileDialog({ |paths|
		var pathName = paths[0];
		var soundFile = SoundFile.openRead(pathName);
		if (soundFile.isNil, {
			postln("File is not a valid soundfile:");
			postln(pathName);
		}, {
			if (soundFile.numChannels != 1, {
				postln("File is not valid - it must be a mono soundfile:");
				postln(pathName);
			}, {
				~soundFile = pathName;
			});
		});
	}, {
		postln("File open was cancelled. Default sound file will be used.");
	});

	// end of optional ====

	*/

	"Preparing data...".postln;

	~soundFile = Platform.resourceDir +/+ "sounds/a11wlk01.wav";
	// ~soundFile = "/Users/paul/Recordings/TestRecordings/LongNoteFilterSweep.aif";

	~outLevel = 0.1;

	// fft settings
	// note the window type and overlaps... this is important for resynth parameters
	~wintype = 1;
	//~wintype = 0;
	~winsize = 2048;
	~hop = 0.15;
	//~hop = 0.25;
	~hop = (~hop * 2048 / 64).round * 64 / 2048;

	~brushTypeOptions = [
		["Brush: Draw (Gradient)", \drawGradient],
		["Brush: Draw (Uniform)", \drawUniform],
		["Brush: Erase (Gradient)", \eraseGradient],
		["Brush: Erase (Uniform)", \eraseUniform],
	];
	~brushType = \drawGradient;  // see ~brushOptions

	~playRate = 1.0;
	~playRateOptions = [
		["...", 1.0],
		["1.0", 1.0],
		["0.1", 0.1],
		["0.3", 0.3],
		["0.5", 0.5],
		["0.75", 0.75],
		["1.25", 1.25],
		["1.5", 1.5],
		["3.0", 3.0],
		["5.0", 5.0],
		["10.0", 10.0],
	];

	~brushWidth = 10;   // range 1- 100
	~brushHeight = 10;  // range
	~brushSizeOptions = [
		["1", 1],
		["2", 2],
		["3", 3],
		["5", 5],
		["8", 8],
		["10", 10],
		["15", 15],
		["20", 20],
		["25", 25],
		["30", 30],
		["40", 40],
		["50", 50],
		["75", 75],
		["100", 100],
	];
	~brushIntensity = 0.5;   // range 0-1

	~windowWidth = 860;
	~windowHeight = 1060;
	~buttonColor = Color(0.25, 0.40, 0.8);

	("Opening file:" + ~soundFile).postln;

	~inbuf.free;~inbuf = Buffer.read(s, ~soundFile);

	s.sync;

	~buf.free;~buf = Buffer.alloc(s, ~inbuf.duration.calcPVRecSize(~winsize, ~hop, s.sampleRate));

	s.sync;

	SynthDef("pvrec", { |recbuf, inbuf, winsize = 2048, hop = 0.25, wintype = 1|
		var in, chain, bufnum;
		bufnum = LocalBuf.new(winsize, 1);
		Line.kr(1, 1, BufDur.kr(inbuf), doneAction: 2);
		in = PlayBuf.ar(1, inbuf, BufRateScale.kr(inbuf), loop: 0);
		chain = FFT(bufnum, in, hop, wintype);
		chain = PV_RecordBuf(chain, recbuf, 0, 1, 0, hop, wintype);
		// no ouput
	}).add;

	s.sync;
	("File length:" + ~inbuf.duration.round(0.01) + "secs").postln;
	"Analysing sound file ...".postln;

	a = Synth("pvrec", [recbuf: ~buf, inbuf: ~inbuf, winsize: ~winsize, hop: ~hop, wintype: ~wintype]);

	s.sync;

	// wait for sound to finish
	(~inbuf.duration + 0.5).wait;

	SynthDef("pvplay", { |out, buf = 1, offset = 0, rate = 1, winsize = 2048, hop = 0.25, wintype = 1, outLevel = 0.1|
		var in, chain, bufnum;
		bufnum = LocalBuf.new(winsize);
		chain = PV_PlayBuf(bufnum, buf, rate, offset, 0);
		Out.ar(out, IFFT(chain, 1).dup * outLevel);
	}).add;

	s.sync;

	/*
	//   Optional:

	"Play sound once ...".postln;
	b = Synth("pvplay", [out: 0, buf: ~buf, rate: 1, winsize: ~winsize, hop: ~hop, wintype: ~wintype, outLevel: ~outLevel]);
	// wait for sound to finish
	(~inbuf.duration + 0.5).wait;
	// stop the synth
	b.free;

	// end of Optional
	*/


	// load to ~data array
	~buf.loadToFloatArray(action: { |data| ~data = data });

	s.sync;
	"Preparation completed.".postln;

	(
		// ==== Build GUI ============

		// gui defer
		defer ({
			var playFunc, stopFunc, rewindFunc, makeSliderNumFunc;
			var sampleRate = s.sampleRate;
			var numFrames = ((~data.size - 3) / ~winsize);

			var width = numFrames.asInteger;
			var height = (~winsize / 2).asInteger;
			var colorFunc = { |mag| Color.gray(1, mag.log10.lincurve(-2, 2, 0, 1, curve: 0)) };
			// note: scaleFunc not used in this version - only linear works correctly
			// var scaleFunc = { |pixel| pixel.lincurve(0, height, 0, height, curve: 3) };
			// curve factor for Y scaleing (0 = linear, 3 =~ mel scale)
			var endOfFile = false;
			var needLoadData = false;
			var volumeSpec = ControlSpec(0, 2, warp: 2);
			var playRateSpec = ControlSpec(0.1, 10, warp: 'exp');
			var intensitySpec = ControlSpec(0.1, 10, warp: 'exp');
			var columnWidth = 180;
			var volNumBox, volSlider, playRateNumBox, playRateSlider;

			~synth = nil;
			~playhead = 0;

			playFunc = {
				// stop then play
				stopFunc.value;
				if (~synth.isNil) {
					~synth = Synth("pvplay", [
						out: 0,
						buf: ~buf,
						offset: ~playhead / ~inbuf.duration * numFrames,
						rate: ~playRate,
						winsize: ~winsize,
						hop: ~hop,
						wintype: ~wintype,
						outLevel: ~outLevel
					]);
					//~playhead = 0;
					~playrout = fork {
						(~inbuf.duration / ~playRate * 31).do {
							~playhead = ~playhead + (~playRate / 30);
							defer { ~playheadview.refresh };
							if ((~playhead < (~inbuf.duration)), {
								(1/30).wait;
							}, {
								endOfFile = true;
								defer {stopFunc.value};
								(1/30).wait;
							});
						};
					};
				};
			};
			stopFunc = {
				if (~synth.notNil) {
					~synth.free;
					~synth = nil;
					~playrout.stop;
				};
				if (endOfFile, {
					~playhead = 0;
					defer { ~playheadview.refresh };
					endOfFile = false;
				});
			};
			rewindFunc = {
				endOfFile = false;
				~playhead = 0;
				~playheadview.refresh;
				if (~synth.notNil) {
					defer { playFunc.value};
				};
			};

			~image = Image.new(width, height);

			~magAt = { |frame, index|
				~data[(frame * ~winsize) + (index * 2) + 4];
			};
			~setMag = { |frame, index, mag, phase|
				var i = (frame * ~winsize) + (index * 2) + 3;
				~data[i + 1] = mag;
				if (phase.notNil) {
					~data[i] = phase;
				};
			};

			if (~win.notNil) { ~win.close };
			// ~win = Window("", Rect(0, 0, ~image.width + 160, ~image.height + 20)).front.background_(Color.white);
			~win = Window("", Rect(0, 0, ~windowWidth, ~windowHeight)).front.background_(Color.white);
			~win.view.keyDownAction_({ |v, char|
				if (char == $ ) {
					if (~synth.notNil) {
						stopFunc.value;
					} {
						playFunc.value;
					}
				};
			});
			~guiColumn = View(~win.view, Rect(0, 0, columnWidth + 10, ~windowHeight - 16)).background_(Color(0.93, 0.97, 1));
			~guiColumn.addFlowLayout( 4@4, 4@4 );
			// Space bar info
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 40)).align_(\center)
			.string_("Press Space Bar \n to Play/Stop")
			.background_(Color(0.8, 0.9, 1)).stringColor_(Color.black);
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// Volume
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Volume")
			.background_(Color(0.8, 0.9, 1)).stringColor_(Color.black);
			// slider
			volSlider = Slider(~guiColumn, Rect(4, 194, columnWidth - 44, 24))
			.thumbSize_(9).knobColor_(Color.white)
			.background_(~buttonColor.blend(Color.grey))
			.value_(volumeSpec.unmap(~outLevel))
			.action_({ |v|
				~outLevel = volumeSpec.map(v.value);
				volNumBox.value = volumeSpec.map(v.value);
				if (~synth.notNil) {
					~synth.set(\outLevel, volumeSpec.map(v.value));
				};
			});
			// numbox
			volNumBox = NumberBox(~guiColumn, Rect(0, 0, 40, 24))
			.value_(~outLevel)
			.action_({ |v|
				v.value = volumeSpec.constrain(v.value);
				~outLevel = v.value;
				volSlider.value = volumeSpec.unmap(v.value);
				if (~synth.notNil) {
					~synth.set(\outLevel, volumeSpec.constrain(v.value));
				};
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// Rewind
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Rewind")
			.background_(~buttonColor).stringColor_(Color.white)
			.mouseDownAction_({ |v, x, y|
				rewindFunc.value;
			});
			// Play
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Play")
			.background_(~buttonColor).stringColor_(Color.white)
			.mouseDownAction_({ |v, x, y|
				playFunc.value;
			});
			// Stop
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Stop")
			.background_(~buttonColor).stringColor_(Color.white)
			.mouseDownAction_({ |v, x, y|
				stopFunc.value;
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// Play Rate
			StaticText(~guiColumn, Rect(0, 0, columnWidth - 44, 24)).align_(\center)
			.string_("Play Rate")
			.background_(Color(0.8, 0.9, 1)).stringColor_(Color.black);
			// options popup
			PopUpMenu(~guiColumn, Rect(0, 0, 40, 24))
			.background_(Color.grey(0.9))
			.items_(~playRateOptions.flop[0])
			.value_(0)
			.action_({arg v;
				var wasPlaying = ~synth.notNil;
				stopFunc.value;
				~playRate =  ~playRateOptions.flop[1][v.value];
				playRateNumBox.value = ~playRate;
				playRateSlider.value = playRateSpec.unmap(~playRate);
				if (wasPlaying, {
					playFunc.value;
				});
				v.value = 0;
			});
			// slider
			playRateSlider = Slider(~guiColumn, Rect(4, 194, columnWidth - 44, 24))
			.thumbSize_(9).knobColor_(Color.white)
			.background_(~buttonColor.blend(Color.grey))
			.value_(playRateSpec.unmap(~playRate))
			.action_({ |v|
				playRateNumBox.value = playRateSpec.map(v.value);
			})
			.mouseUpAction_({ |v|
				var wasPlaying = ~synth.notNil;
				stopFunc.value;
				~playRate = playRateSpec.map(v.value);
				playRateNumBox.value = playRateSpec.map(v.value);
				if (wasPlaying, {
					playFunc.value;
				});
			});
			// numbox
			playRateNumBox = NumberBox(~guiColumn, Rect(0, 0, 40, 24))
			.value_(~playRate)
			.action_({ |v|
				var wasPlaying = ~synth.notNil;
				stopFunc.value;
				v.value = playRateSpec.constrain(v.value);
				~playRate = v.value;
				playRateSlider.value = playRateSpec.unmap(v.value);
				if (wasPlaying, {
					playFunc.value;
				});
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// brush Type
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Brush Type")
			.background_(Color(0.8, 0.9, 1)).stringColor_(Color.black);
			ListView(~guiColumn, Rect(0, 0, columnWidth, 70))
			.background_(Color.grey(0.9))
			.items_(~brushTypeOptions.flop[0])
			.value_(~brushTypeOptions.flop[1].indexOf(~brushType))
			.action_({arg v; ~brushType = ~brushTypeOptions.flop[1][v.value]});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// brush intensity
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Brush Intensity")
			.background_(Color(0.8, 0.9, 1)).stringColor_(Color.black);
			Slider(~guiColumn, Rect(0, 0, columnWidth, 24))
			.thumbSize_(9).knobColor_(Color.white)
			.background_(~buttonColor.blend(Color.grey))
			.value_(intensitySpec.unmap(~brushIntensity))
			.action_({ |v|
				~brushIntensity = intensitySpec.map(v.value);
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// brush width
			ListView(~guiColumn, Rect(0, 0, columnWidth, 70))
			.background_(Color.grey(0.9))
			.items_((~brushSizeOptions.flop[0]).collect({arg i; "Brush width:" + i}))
			.value_(~brushSizeOptions.flop[1].indexOf(~brushWidth))
			.action_({arg v;
				~brushWidth = ~brushSizeOptions.flop[1][v.value];
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// brush height
			ListView(~guiColumn, Rect(0, 0, columnWidth, 70))
			.background_(Color.grey(0.9))
			.items_((~brushSizeOptions.flop[0]).collect({arg i; "Brush height:" + i}))
			.value_(~brushSizeOptions.flop[1].indexOf(~brushHeight))
			.action_({arg v;
				~brushHeight = ~brushSizeOptions.flop[1][v.value];
			});
			// spacer
			~guiColumn.decorator.top = ~guiColumn.decorator.top + 20;
			// clear image
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Clear Image")
			.background_(~buttonColor).stringColor_(Color.white)
			.mouseDownAction_({ |v, x, y|
				stopFunc.value;
				~data = ~data[0..2] ++ (~data.size - 3).collect { 0 };
				~data[0] = ~winsize;
				~data[1] = ~hop;
				~data[2] = ~wintype;
				~buf.loadCollection(~data, action: { "loaded".postln; {~view.refresh;}.defer });
				~image.fill(Color.black);
				~view.refresh;
				rewindFunc.value;
			});
			/*
			// for testing to refresh whole image ====
			// manual refresh button
			StaticText(~guiColumn, Rect(0, 0, columnWidth, 24)).align_(\center)
			.string_("Refresh")
			.background_(~buttonColor).stringColor_(Color.white)
			.mouseDownAction_({ |v, x, y|
			~refreshImage.();
			~view.refresh;
			});
			*/

			~makeView = {
				var mouseDownPoint, currentPoint;
				~refreshImage = {arg rect;
					rect = rect ? Rect(0, 0, ~image.width, ~image.height);
					~image.setPixels(Int32Array.fill(rect.width * rect.height, { |i|
						var index = ((i / rect.width).floor).asInteger;
						var frame = i % rect.width;
						var mag = ~magAt.((rect.left + frame), ~image.height - (rect.top + index));
						Image.colorToPixel(colorFunc.(mag));
					}), rect);
				};
				~refreshImage.();

				~scrollView = ScrollView(~win, Rect(columnWidth + 20, 4, ~windowWidth - 160, ~windowHeight - 16))
				.resize_(5).autohidesScrollers_(false);
				// ~view.remove;
				// ~playheadview.remove;
				~view = UserView(~scrollView, Rect(0, 0, ~image.width, ~image.height)).drawFunc_({
					Pen.use {
						Pen.scale(~view.bounds.width / ~image.width, ~view.bounds.height / ~image.height);
						Pen.drawImage(0@0, ~image)
					}
				}).background_(Color.black);

				~playheadview = UserView(~view, Rect(0, 0, ~image.width, ~image.height)).drawFunc_({ |v|
					Pen.use {
						Pen.addRect(Rect((~playhead / ~inbuf.duration * v.bounds.width).max(1), 0, 2, v.bounds.height));
						Pen.color = Color.red;
						Pen.fill;
					}
				}).background_(Color.clear)
				.mouseDownAction_({ |v, x, y|
					// [x, y].postln;
					mouseDownPoint = x@y;
				})
				.mouseMoveAction_({ |v, x, y|
					var index = height - 1 - y;
					var frame = x;
					var halfBW = (~brushWidth / 2).asInteger;
					var halfBH = (~brushHeight / 2).asInteger;
					var minX, maxX, minY, maxY, updateRect;
					var mag, magChange, distance;
					~brushWidth.do { |bwi|
						~brushHeight.do { |bhi|
							var thisFrame = (frame - halfBW + bwi);
							var thisIndex = (index - halfBH + bhi);
							if (thisFrame.isPositive and: {thisIndex.isPositive}
								and: {thisFrame < v.bounds.width} and: {thisIndex < v.bounds.height}
							) {
								mag = ~magAt.(thisFrame, thisIndex);
								// gradient or uniform
								if ((~brushType == \drawGradient) or: (~brushType == \eraseGradient), {
									distance = sqrt((thisFrame - frame).squared + (thisIndex - index).squared);
									magChange = distance.lincurve(0, [~brushWidth, ~brushHeight].mean, ~brushIntensity, 0, -5);
								}, {
									magChange = ~brushIntensity;
								});
								// draw or erase
								if ((~brushType == \drawGradient) or: (~brushType == \drawUniform), {
									// ~setMag.(thisFrame, thisIndex, (mag + magChange), rrand(0, 2pi));
									~setMag.(thisFrame, thisIndex, (mag + magChange).min(100), rrand(0, 2pi));
								}, {
									// ~setMag.(thisFrame, thisIndex, (mag - magChange), rrand(0, 2pi));
									~setMag.(thisFrame, thisIndex, (mag - magChange).max(0), rrand(0, 2pi));
								});
							}
						}
					};
					minX = (x - halfBW).max(0);
					maxX = (x + halfBW + 1).min(v.bounds.width - 1);
					minY = (y - halfBH).max(0);
					maxY = (y + halfBH + 1).min(v.bounds.height - 1);
					updateRect = Rect.fromPoints(Point(minX, minY), Point(maxX, maxY));
					~refreshImage.(updateRect);
					~view.refresh;
					needLoadData = true;
				})
				.mouseUpAction_({ |v, x, y|
					var minX, maxX, minY, maxY, halfBW, halfBH, updateRect;
					if (x@y == mouseDownPoint) {
						~playhead = x / v.bounds.width * ~inbuf.duration;
						~playheadview.refresh;
						if (~synth.notNil) {
							playFunc.value;
						};
					};
					if (needLoadData, {
						// load data to buffer
						~buf.loadCollection(~data, action: { /* "loaded".postln */ });
						needLoadData = false;
						~view.refresh;
					});
				});
			};
			~makeView.();
		});
		// end of gui defer
	)

});   // end of waitForBoot
)
3 Likes