Tutorials

Winter's Day gen~: The Wavetank

The ZenSlap, in whatever form, shakes together pre-existing knowledge in the recipient's head, so they see what they (in principle) already know but didn't see. The right slap can bring two thoughts together that had been sitting next to each other, but unconnected, for a long time... or a set of thoughts needing only one more to make a complete set. At the wrong time, a slap (finding only the one thought) only hurts. The art of the master is to recognize when the thoughts are there.

On A Winter's Day....

So I’m sitting at my desk practicing trying to “empty my mind and think of nothing” on a Wisconsin winter day. The sun’s out, but not heating things up all that much, and I’m having trouble thinking of nothing. Instead, I’m idly glancing around my workspace when my eye falls upon my Eurorack setup. I’ve just taken all the patchcords out, figuring that nowhere might also be a good place to start when Euroracking, too.

My eye falls on the snowy white front panel of the 4MS Spherical Wavetable Navigator in my rack, and I start wondering about that module. Without warning, I suddenly find myself thinking about gen~ patching.

That’s how it started.

When I got the module, I spent quite a lot of time just modulating the controls for the module and trying to make some sense of the results. That afternoon, I found myself thinking about doing a little wavetable-based gen~ patching that might duplicate or somehow mirror my hours of modulating Eurorack fun.

To refresh my memory, I decided to download the module’s manual and read about how the 4MS folks conceived of the design. That, in turn, pointed me to their SphereEdit (available for Mac or Windows) application and a nice collection of wavetables. While the manual was clear and helpful, although I guess that the idea of a sphere was metaphorically challenging. In a flash of insight, I realized that I ought to be thinking about interpolating and folding as well - and suddenly things kind of fell into place. I started thinking not of a sphere, but of an Escheresque apartment building whose stairs to the roof left you on the first floor, and….

The more I thought about it, the more I thought it’d be interesting to try implementing the idea in gen~ at single-sample accuracy using the wave operator. Since I um… work slowly (Yeah, that’s it. I work slowly), I thought that I’d start with something modest based on that white-panel SWN module sitting over there in my Rack.

Let’s work with one set of wavetables, and just set up a simple readback and experiment with a little modulation and see how that goes.

This is the record of that idea. To be honest, the duration was more than a winter’s day, and - now that it’s working - I think I have some more enjoyable days ahead, too.

Note to Ableton Live Suite users: Access to Gen requires a full Max license. Get details on crossgrading!

Wavetank_tutorial_patches.zip
application/zip 60.69 KB
Download the patches used in this tutorial....

The Basic Idea

Okay. Imagine that you have 27 waveforms arranged as a “cube.” You have three "stacked" 3 x 3 matrices of waveforms. The output sound will be the result of mixing together three sets of interpolated waveforms, based upon the horizontal (X), vertical (Y), and depth (Z) position in the cube at any given time.

Illustration courtesy of Darwin Grosse

This idea goes in all kinds of interesting directions. For one thing, any new trio of 3D values will result in a unique waveform. In addition, if that trio of values is generated as a result of some kind of movement, the output will smoothly crossfade from one waveform to the next - you can modulate one axis to interpolate horizontal, vertical or depth-based modulations, or as many as you’d like. You can use time-repeating algorithms to modulate your waveforms relative to each other, experiment with different ways to describe trajectories inside of the “cube,” and so on.

Some version of this idea is in play all over the current landscape of wavetable synthesis - from the Eurorack Spherical Wavetable Navigator that started me thinking about this in the first place to the lovely Morphing Terrarium or current keyboard synths such as the Hydrasynth, the Argon8, or the new Korg Wavestate.

Starting Out

The Spherical Wavetable Navigator manual gave me a description of the wavetable files I'd be working with - the files intended for reading into the Eurorack module’s buffer were composed of some number of single-cycle waveforms, laid end-to-end. Each of those single-cycle waveforms is going to correspond to one of the waveforms that make up the “cube” (or, as the Spherical Wavetable Navigator calls it, the “sphere”).

I think it’s easier for me to use the cube as a metaphorical starting point, with the additional notion that you can think of each single-cycle waveform in the cubic arrangement as a room — where each wall and floor between the single-cycle waveform rooms is acoustically transparent, and every wall/floor/ceiling has the same sounds in their corners as well.

Since I was going to be interpolating among the single-cycle waveforms in the file, I needed to have a way for my gen~ patcher to load a buffer operator and figure out a way to identify the start and end points of any of those 27 single-cycle waveforms in the file, sample by sample.

Happily, the buffer operator will, when loaded, start broadcasting the total number of samples it contains. I know that I want my “cube” to have three different waveforms along each of the axes in the cube, so this little bit of gen~ patching lets me set a parameter for that number of waveforms along each axis, and then to cube that number and calculate how many samples will be in each single cycle waveform:

Since a bunch of the calculations I’ll be doing requires that number of axes, using a param operator lets me use that value throughout my patch. In addition, storing that number of samples in each of the 27 waveforms using the history operator will also let me use that value later on, as well. Now that I’ve got a way to determine who many samples constitute a single-cycle waveform based on the length of my buffer and the number of waveforms, what does playback look like?

The file 01_Wavetable_play_patch shows a simple but diverting example of selectable playback using the gen~ wave operator.

Let’s take a look inside the gen~ patcher choose_a_waveform:

You’ll quickly recognize the same buffer operator-based patching we just used to set the length of a single-cycle waveform in samples. Once we have that number and we know the number of waveforms in our file (27). Then it’s easy to specify the values that correspond to the start and endpoints of our single-cycle waveform (which is what the wave operator needs as its second and third inputs): We take a value between 0 and 26 (the table_index parameter) and multiply it by the number of samples in each single-cycle waveform to get our starting point. Adding that same number again gives us the endpoint sample value. We use a phasor~ object in the parent patch to control the playback rate. For extra fun, the patcher choose_a_waveform patcher uses a standard Max metro and counter object to automate stepping through the waveforms in the file.

Moving in 3D

Now that we know how to divide my buffer into the right number of N-sample chunks and to select the waveform I want to play, it’s time to think about what moving between waveforms in my “cube” actually entails.

For each of the three positions that describe the “space” in my cube, I’ll have three versions of the same thing: I’ll be interpolating between two waveforms for each of the X, Y, and Z coordinates. And then interpolating all three of them together.

So that means I’ll need three values in the range of 0 - 3.0, and - since I’m interested in interpolating at the “edges” I”ll need to be able to wrap values at the minimum and maximum of the range.

Let’s look at the layout of our single-cycle waveforms again:

  • Interpolation along the X axis moves between waveforms 0, 1, and 2 or 12, 13 and 14 (in the second “plane”, 24, 25, and 26 in the third “plane”, etc..

  • Interpolation along the Y axis moves between waveforms 0, 3, and 6 or 9, 12 and 15 (in the second “plane” 20, 23, and 26 in the third “plane”, etc..

  • Interpolation along the Z axis moves between waveforms 2, 11, and 20, 8, 17, and 26 (in the second “plane”, 6, 15, and 24 in the third “plane”, etc.

The summing of the axis-to-index inlets is crucial, and the way we connect 1D space (the set of wavetables in the buffer) to a 3D space bears a little more explanation.

As in the example above, when we think of the X axis of our collection of waveforms, the wavetables are end-to-end, with a spacing of 1 (e.g. 0, 1, and 2). Since our tables_per_axis = 3, that means the axis requires 3 wavetables.

When we think of the Y axis, we have to then jump every 3 wavetables (to give space for the X axis). Moving 1 step through Y means jumping over 3 wavetables (e.g. 0, 3, and 6). So we end up needing 3x3=9 slots to cover X and Y.

In the the Z axis, we have to then jump every 9 wavetables (to give space for the X and Y axes). Moving 1 step through Z means jumping over 9 wavetables (e.g. 0, 9, and 18). So we end up needing 9x3=17 slots to cover X, Y and Z.

The lion’s share of my time in patching this involved trying to puzzle out how to translate three values in the range of 0 - 3.0 so as to come up with the two waveforms I wanted to interpolate between (i.e. to identify the start and endpoints as sample numbers) and the interpolation value between the two waveforms to be applied to them, and then to come up with a way to mix those three separate results.

That’s what the gen~ wavetank patch does.

It looks sort of complicated, but I’ll break it down into the individual pieces and walk you through them.
You’ve already seen how we can derive the sample length of waveforms on load, so let’s move along….

Axes To Indices

First, we need to have a way to convert one of the X, Y, or Z values into information about waveform numbers and interpolation amounts for our 3 x 3 x 3 cube of waveforms. The three axis_to_index patchers take care of that for us.

Each axis_to_index gen patcher takes a continuous index value as its input. You’ll notice that we’re making use of the tables_per_axis parameter we use in the parent wavetank patcher for calculating the length of each individual waveform in samples. That value shows up in this patcher because we need to specify that any value we receive in the patcher’s input will be properly wrapped to be in the range of 0. – 3.0

Once we wrap the X, Y, or Z input value, we convert it to two integer values that specify the two nearest neighbors in our sequence of waveforms by converting the axis value to an integer and adding 1 to it, and using the tables_per_axis parameter once again to make sure we wrap the nearest neighbor back to 0 if the current integer value is 3.

So we’re ready to begin reading from our file full of waveforms. But remember – that file full of waveforms is a linear group of single-sample waveforms. How do we decide what the sample offset to start reading for any of our 27 waveforms is?

We need a way to take those nearest neighbor integer values from the axis_to_index gen patcher and to scale them up to corresponding offsets in the wavetables file. Once again, we can use the tables_per_axis parameter to help us out by multiplying the two index integer values by 0 (which always gives us a value of 1.), 3 (1 * the number of tables per axis) and 9 (the tables_per_axis value squared).

We’re now ready to start reading from our buffer full of waveforms!

By the way – you might have been reading along and sort of wondering why I bothered to create a parameter that described the number of tables per axis for this patch instead of just plugging in a 3 everywhere I need to reference the number of tables per axis. You see that sequence of multiplication operators in the wavetank patch (* 1, * tables_per_axis, and * tables_per_axis * tables_per_axis) that we use to get the 1, 3, and 9 values we use for the waveform offsets. It turns out that using a param operator to hold that value here and elsewhere is going to let us do something really cool later on. Stay tuned!

Reading the Wavetables File

The middle of our wavetank gen patcher is where we really get down to the business of reading values from our buffer full of waveforms and then creating the audio output. One thing you’ll notice is that we’re using the playback phasor (inlet 1 in our wavetank gen~ patcher) to set the rate at which we read and write samples. But why are there four read_the_wavetable gen patchers? And – more to the point, why are they reading from the buffer twice in each of the read_the_wavetable gen patchers, for a total of eight wavetable readers?

The answer has to do – once again – with that question of dimensionality.

Let’s start with a simple case: if you’re using a crossfader in your DJ rig, you’re mixing in 1 dimension. You need two sources (or two wavetable readers) to handle the crossfade between input 1 (call it A) and input 2 (call it B).

Now, suppose you want to mix in two dimensions. It’s a square instead of a line, and your square has 4 corners, so you need to double what you had in the single dimension – a pair of sources for each A and B (AA, AB, BA, BB).

But we’re wanting to mix in three dimensions, which means we’re dealing with a cube instead of a square, that means we need to double the number of sources again (AAA, AAB, ABA, ABB, and so on), giving us eight sources.

So – for N axes in the space we’re trying to mix in you need 2^N (2 x 2 x 2 =8) sources to mix. A line has two ends, a square has four corners, and a cube has eight corners. Presto – eight wavetable readers, organized as four groups of two (That means that your hypercube mixing wavetank is going to need a lot more wavetable readers - 16 of 'em).

That idea of performing interpolation in three dimensions on data is something that folks in the graphics world do pretty commonly – it even has a name: trilinear interpolation or box interpolation. One place you’re likely to find it in use in the graphics world involves doing lookups in a 3D texture or a voxel grid. For more information on trilinear interpolation, Paul Bourke’s website has a great page on interpolation methods you might even want to bookmark. The page is here — scroll down to the “Trilinear interpolation” section.

And yes, this is the same Paul Bourke whose collection of code for Chaotic attractors is the site I’ve directed anyone nearby who would listen. Paul’s site is a fount of knowledge and clarity.

Now that we have an idea of why we need all that wavetable reading done and why we need to mix and interpolate information for all three axes, let’s look inside the read_the_wavetable gen patcher.

First, you’ll notice that we’re using the same patcher for each of our four pairs of buffer reading we’re doing, and that each and every single one of the read_the_wavetable gen patchers is using the wavetable phasor in the main patch to control the rate at which it’s reading and writing values (the in 1 operator).

Remember those three index values (two integers and a floating-point interpolation value) we got from the axis_to_index patcher and scaled up using the tables_per_axis parameter value? Those are represented by in 2, in 3, and in 5. The integer index values of in 2 and in 3 are the “nearest neighbor” values that we’ll use to set the start and the endpoints we’ll need when reading from our buffer. We get those start and endpoints by using the table_length parameter that corresponds to the number of samples for each individual waveform in the wavetable file. We calculated that value when the buffer was initially loaded. The table indices (multiplied by 1, 3, or 9) are multiplied by the table_length variable to get the initial offset into the buffer, and the endpoint is the sum of that offset plus the table length.

The gen~ wave operator handles the buffer-based wavetable reading. At the rate set by the phasor waveform input, it samples from the buffers at the specified locations, and then mixes the two outputs based on an interpolation value.

This same sequence is repeated for each of the other three read_the_wavetable gen patchers, which are, in turn, to achieve the desired 3D waveform result.

A Question of Scale

Once I had all that wrapped up together, I had the patch you'll find in the tutorial patches folder called 02_Wavetank_3x3x3. I could now grab the audio files from the SphereEdit application and play load and go....

But remember my earlier comment about deciding to set the number of tables for each axis of my wavetank using the tables_per_axis parameter rather than salting my patch with the number 3?

Going the parameter route allowed me to do something super cool. Suppose I want to work with a collection of sixty-four single-cycle waveforms — a 4 x 4 x 4 cube instead of 3 x 3 x 3. There are a whole lot of wavetable files out there in that form (these, for example). What kinds of modifications do I need to make to my patch?

None. Zip. Zilch, Nada.

All I need to do is to set the value of my tables_per_axis parameter to 4 instead of 3 (with the help of a handy Max attrui object) and I’m ready to go. The patching subtleties of working with the larger cube (with a new excursus value of 0. - 4.0 which will be automatically set, by the way) are exactly the same.

I’ve included a file of 64 single-cycle waveforms in the downloadable folder (PPG_WA00.WAV) for you to try, and I'd suggest that you head over to the Synthesis Technology WaveEdit online page to download a whole bunch of lovely 64-waveform files.

All you need to do is to change the tables_per_axis
parameter and read in a new file.

You’re welcome.

All Mod(ulations) Cons(idered)

Once I had my gen~ patch working, I decided to spend a little time exploring. I’d be lying to you if I didn’t just own up to the fact that there was some space between the understanding of the gen~ patch before me and what I heard. In the interests of getting more of that knowledge into my head through my ears, I put together a simplified version of my patch (the 03_Wavetank_starter_patch) that let me manipulate each of the X, Y, and Z axis values manually (I even decided to try it using my newfound 4 x 4 x 4 cube, just for fun).

It worked great - I could quietly commune with the patch and exercise each axis value for my cube individually and get a sense of the relationship between modulating the X, Y, or Z axes individually, and combining them.

After that, the next move is obvious — modulate those axes! And thus, the 04_Wavetank_EZ_rate patch was born. Nothing special here. Just a couple of rate~ objects to set up a sequence of sine waves (phasor~ > rate~ > cycle~) for modulating fun.

There are all kinds of modulation possibilities, obviously (3-output chaotic attractors come to mind, for example….). But I’d like to share something in the 05_Wavetank_GW_modulation patch from my friend and colleague Graham Wakefield (Graham is the man who gen~ calls “Daddy,” in case you didn’t know).

I was in touch with Graham in the course of developing these patches. At one point in our correspondence, he sent me an explanation/suggestion that included the gen~ patcher that appears as (GW_modulator).

It included some clever patching making use of two of the numerical constants that Gen includes — Pi and Phi. He was pretty humble about it, claiming that he “just wanted something that wouldn’t repeat”; Since Phi and Pi are transcendental numbers, they have no simple ratios with other numbers.

I’ll let you explore the behavior of this elegant little hack yourself - just load a wavetable file, make sure that the tables_per_axis values are set for the file you read in, set the modulation_rate value to something sweet and slow, and settle back to enjoy.

Quo Vadis?

I started out talking about inspiration and enlightenment. While it’s common to hear people talking about inspiration as though the relation between the idea that set things in motion is somehow completed in a given instance of what results from that initial idea, that doesn't feel like that's the case here. I look and listen and imagine what might be next.

And - for me - the next isn't necessarily a case of trying to slavishly emulate my Eurorack module. I’ve only ported a small portion of the Spherical Wavetable Navigator’s functionality, but as the wavetank cycles quietly in the background, I find myself thinking along very different lines, and already wondering how I can use gen~ to follow those lines of thinking. Feel free to share where this bit of gen~ patching may take you, and happy patching!

I am very grateful to Graham Wakefield for his patience in giving me the right metaphors by which I could start unravelling the idea of trilinear interpolation, as well as for his lovely Pi/Phi nonrepeating modulator hack. He shreds and rules.

by Gregory Taylor on January 21, 2020

clairobskur's icon

this is very interesting! thanks

topher46's icon

Lots of fun, thanks for taking my brain/ears through this. One thing that is unclear for me, the "27 single-cycle waveforms" are each 512 samples in "3x3x3.WAV" . Looking at the waveform in an editor, these are not single cycles of a (assumedly sine) waveform, rather, they are enough periods of a waveform to constitute 512 samples. This works with the overtone series where there is arithmetic growth, but would not work with arbitrary waveforms unless they are always 512 (or whatever division of the buffer one needs for the matrix be it 3x3x3 or otherwise). That all said, I believe these are not single cycles, but rather, they are 512 sample chunks (in the case of 3x3x3) and need to be created as such. Is that correct?

Gregory Taylor's icon

Hey, Topher. Yes, you would be dealing with sample count here, so for buttery smoothness, all of your waveforms would need to be the same length. So yeah - harmonics work just fine in terms of periodicity.

Otherwise, you're looking at 27 (or 64) sets of samples of N length. I've not found that to be a terrific problem, and — in fact — there is a bit of audio of Jeremy Irons reading T.S. Eliot's poem "East Coker" loaded into my patch right now (Out at sea the dawn wind/Winkles and slides. I am here/Or there, or elsewhere. In my beginning) rippling and corruscating away quite nicely. Interesting to try different values for the tables_per_axis parameter, too. :-)

Graham Wakefield's icon

If you want to use the wavetank as an oscillator, each 512 chunk of samples should be a single period of a waveform (which might involve multiple periods of harmonics); but if you want to use it as a 3D mixer, you can put whatever you like in there -- just it would be recommended that the start and end points of your 512 chunk are near zero-valued (so that it cycles cleanly), or else you may start to hear clicks/buzzes.

Graham Wakefield's icon

BTW here's where I took this -- enjoy!

Max Patch
Copy patch and select New From Clipboard in Max.

topher46's icon

@Graham - Sounds great! Will wrap my head around the patching in the near future, my patches always feel so brute force when I look @ yours, very elegant! Thanks for making the point about zero crossings, that was what I was driving at in the initial example and as noted, while other things work, they could introduce artifacts (and artifacts can be so lovely!)

Roman Thilenius's icon


my biggest 3d wavetable oscillator was 10x10x10 and i quickly gave up on it because it simply takes too much CPU, because even when you disable all wave~s which are currently not in use - or a direct neighbour to those which are selected - there would still be up to 4x4x4=64 wave~ objects running at the same time.

so sometimes i wonder how much CPU one could save for things like that when using gen instead of MSP? i dont use gen yet and have no experience whether it is worth the effort.

however, 3d wavetables are fun, and it makes a lot of sense to create sets of waveforms for it where each axis performs a certain movement (like say X for a lowpassfilter, Y for mixing the base frequency against/with an osc-sync tone, and Z for shifting everything up)
to control it you wouldnt use a 3d-controller, no, but just 3 sliders, so that you can experience the algorithms of the axis´ individually when you play it.


Roman Thilenius's icon


"rather, they are enough periods of a waveform to constitute 512 samples. This works with the overtone series where there is arithmetic growth, but would not work with arbitrary waveforms unless they are always 512"

you can´t avoid this, more or less, that is how most wavetables work.

but what you can do if you want to have 2 partials with a frequency relation of 1 : 1.5 is that you simply use partials 2 and 3 - while not using 1 at all anymore, and then you define 2 beeing the base note in the patch, so that your wavetable now has 2 cycles instead of one. (first 2 cycles, then 2 cycles reversed: n n u u)

if you start with partial 8, your wavetable now offers relations of 8 : 9, 4 : 5, 8 : 11 and so on.
if you need 8 : 31 and 2 : 11 at the same time, which doenst fit into a 64 partials wave system anymore , you can use a second, parallel wavetable oscillator for that and mix them together only visually for the user.

the higher you want to go with that technique and the more overtones you allow (64 is normally enough, it covers the whole spectrum when the base note is around 30 Hz), the longer the wavetable waves had to be - instead of 512 you would rather be using f.e. 4096 or 44100.

of course 10x10x10 waves of 44100 samples will require 1,4 gb RAM per oscillator and would have to ship your synth with a bunch of already-rendered wavetables on a 12 TB HDs - but in theory it works fine that way.

Graham Wakefield's icon

Hi Roman,

Give the patchers a whirl, see how the CPU looks. I do expect gen~ does make this more efficient than MSP objects.

I was a bit confused about needing 64 wave~ objects though: for a 3D trilinear interp you just need 8, the nearest 8 corners to the idealized 3D point. Did you mean for a 4D interp? In that case you'd need 16 waves. Did I get the wrong end of the stick?

But yes, the memory footprint is the big issue when having more tables per axis. Not just total RAM size, but also the CPU cache -- the CPU will get bogged down if it has to jump around in memory a lot and that kicks in well below the size of your RAM.

Yep, for non-integer harmonics you can do as you say (lower the fundamental to the GCD of the harmonics you care about), or just mix multiple oscillators at different rates.

There are other issues with wavetables of course -- not least the fact that they can alias when played above their original frequency (=sr/tablesize) and will have other distortions (loss of high frequency content) when played below this frequency. E.g. a 512 sample wavetable in the context of a 48kHz system sampling rate has an optimal playback frequency of about 94 Hz. Below that you will lose top end, above that you might get aliasing. Using better interpolation can minimize the aliasing to some degree, but it can still quickly get pretty harsh when modulating the frequency (phase modulation). I've also been dabbling with some other methods to get much better results in this area, and hope to share soon.

Graham

Stanislav Abrahám's icon

Hi Gregory,
greetings from Czech Republic, central Europe :)
I found your tutorial yesterday…it’s fantastic! thank you for sharing!
It is what I’ve been thinking of last three weeks :)
You are offering the real Z axis as I imagined every time I read Synthesis Technology Morphing Terrarium manual…and was always confused and not able to understand that Z axis in case of SynTech is not axis in 3D sphere but 2D scanning of whole wavetable from start to end beside XY scanning the 8x8 2D matrix.So, basically, this is what I’m able to recognise finally :)
I appreciate possibility to play Wave and Sphere Edit software wavetables in Max you brought! Fantastic!
I have two questions I would like to ask:

1) Interpolation - it is beyond my Max skills to understand the difference between setting interpolation for wave~ and 2dwave~ objects in Max and the interpolation approach with using mix objects in gen. I would like to ask you to explain it a little bit, as I did not get it from tutorial..it looks it’s just level of processing over edge of my knowledge.
I tried modify your patch to build the same structure as SynTech (XY axis for scanning 2D 8x8 waves matrix and Z for scanning whole wavetable). I tried reduce “buffer players” and mix objects but get lost :)

The reason I did it this is that I have borrowed E352 for few days…and it looked that interpolation in E352 is more smooth than I was able to do with 2dwave~ in Max. I took it as challenge to try achieve same result and I thought that I could be able to achieve it with approach to interpolation in your patches.

2) could you please basically direct me how to build mentioned 2d 8x8 wave matrix?
I wanted to compare how different is navigate in 2D matrix and 3D sphere.
I really like Wave Edit generated wavetables and I’m trying to make my own with more clear order of harmonic evolution during scanning wavetable…..and it seems that in case of 3D Sphere/cube it is easier to loose orientation during navigation. The 3D sphere is great and it sounds great, but now I enjoy it more in random “surprising” mode of navigation thru wavetable than for systematic evolving of harmonics in drone sound for example.
I guess, finally it make sense to have more patches with different modes of sculpting sound from wavetable.
I’m attaching what I was able to do - step jumping thru 64 single waves in wavetable (256samples each)
But I could not figure out how to do interpolation.
Thank you for response, I hope that what I was written make sense as my english is not so strong enough for this level of technical terms discussion.

Best,
Stanislav

Stanislav Abrahám's icon

2Dmatrix.zip
application/zip 10.07 KB