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
)