Sei sulla pagina 1di 12

40}

Come sviluppare un oscil-


loscopio su iPhone e
dominare la FFT
di Gianni Alessandroni
9 Aprile 2014

Le librerie di sviluppo iOS contengono veramente migliaia di funzionalità a supporto di


qualunque prodotto si voglia realizzare, sia esso un gioco, un’applicazione enterprise
oppure un semplice oscilloscopio. Alcune di queste funzionalità sono potenti funzio-

disposizione per risultati in real-time veramente impressionanti. Quello che andremo


a vedere oggi è lo sbroglio, fatto senza toccare una riga di codice, dell’applicazione
aurioTouch2 presente nella documentazione di esempio dell’iOS SDK. L’applicazione
-

tale risultato sullo schermo del dispositivo a 20 frame al secondo.

I
n realtà, leggere come si realizza tutto questo, Bluetooth.
vi aprirà la via allo sviluppo di applicazioni di
- Micro-background
venienti dallo smartphone (video o audio) quan- Non è mia intenzione dissertare sulla trasforma-
to dall’esterno tramite device connessi tramite

ELETTRONICA OPEN SOURCE


Approfitta subito del coupon sconto del 20%

Potrai accedere ad articoli Premium e scaricare gli EOS-Book


in PDF ePub e mobi.
Entra in un portale sempre aggiornato con articoli tecnici, progetti e tutorial
innovativi per Elettronici e Programmatori.

Condividi, confrontati e consulta il nostro Forum dove troverai migliaia di


appassionati di Elettronica ed entra direttamente in contatto con gli Autori,
sempre a disposizione per ogni approfondimento.

Visita il sito dedicato agli abbonati

Abbonati ora!
Come sviluppare un oscilloscopio su iPhone e dominare la FFT {41

una piccola nota va fatta: la trasformata di Fou- Per questo credo che questo articolo sia molto
rier “permette di scomporre e successivamente utile a sbrogliare la matassa.
ricombinare, tramite la formula inversa di sintesi non voglio de-
o anti-trasformazione, un segnale generico in scrivere ogni singola riga di codice per focaliz-
frequenze, zare invece l’attenzione sui punti veramente in-
ampiezze e fasi diverse” (grazie Wikipedia). teressanti, in modo che poi possiate realizzare
Quindi, dato un segnale audio, come nell’e- voi stessi lavori derivativi e nel modo che più
sempio che andremo a trattare oggi, è possibile -
scomporlo nelle frequenze di cui è fatto e visua-
lizzare poi l’ampiezza di ogni singola frequenza. comincia”.
La trasformata di Fourier consente anche l’o- Seconda premessa: conoscere Objective-C è
perazione contraria, per cui data la sommatoria sicuramente molto utile, così come avere un
delle sinusoidi, è possibile ricostruire il segnale -
- noscere il C è fondamentale.

frequenze). Dove si comincia


-
Carichiamo l’esempio in XCode 5 . Il metodo -
Per poter eseguire l’applicazione è necessa- viene richiamato
rio avere un dal framework applicativo al termine del lancio
ed aver scaricato ed installato su dell’applicazione e ci consente di avviare il no-
di un Mac l’XCode 5. A questo punto, andiamo stro processo di inizializzazione.
sul menù “Window” -> “Docu-
mentations & API References”
ed apriamo la documentazione.
Sulla ricerca scriviamo “aurio-
Touch2” e selezioniamo la voce
sul raggruppamento “Sample
code”.
Cliccando sul link “Open
Project” potete subito scaricare
il progetto e trovarvelo pronto al
run nell’ambiente di sviluppo. Già queste due istruzioni sono interessanti:
Il codice dell’esempio è piuttosto complesso per
inputProc.inputProc = PerformThru;
-
inputProc.inputProcRefCon = self;
ma dell’arrivo degli storyboard e per la scelta di
rendere tutta l’interfaccia utente in OpenGL. In Nel processore di input viene indicato il metodo
questo mare di codice volto alla presentazione, e lo stesso delegato per ricevere
le parti interessanti quasi si perdono di vista. è di tipo

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {42

. Indichiamo ora, in tempo, della dimensione del


Nel blocco , dopo il caricamento del suono buffer. Essa deve poi corrispondere ad una po-
di pressione del pulsante, arrivano tutte le istru- tenza di 2 (256, 512 ecc.) in riferimento al sam-
zioni di inizializzazione della sessione audio. La ple rate del campionamento.
prima è obbligatoria ed inizializza la sessione
audio, passando il riferimento alla funzione di Float32 preferredBufferSize = .005;
che gestirà le interruzioni.
XThrowIfError(AudioSessionSetProperty...
...(kAudioSessionProperty_PreferredHardwareIOBuffer-
(dichiarata in “
Duration, ...
” che intercetta un valore di ri- ...sizeof(preferredBufferSize), &preferredBufferSize), ...
torno di tipo (ritornato dalle funzioni ...”couldn’t set i/o buffer duration”);
Audio) e genera, in caso di errore, una eccezio-
ne di tipo . Impostata la dimensione del buffer, richiediamo
il sample rate per il dispositivo e lo mettiamo
// Initialize and configure the audio session
nella variabile size.
XThrowIfError(AudioSessionInitialize(NULL, NULL,
rioInterruptionListener, self), ...
...”couldn’t initialize audio session”); UInt32 size = sizeof(hwSampleRate);

XThrowIfError(AudioSessionGetProperty...
Ora andiamo ad indicare quale tipo di uso fac-
...(kAudioSessionProperty_CurrentHardwareSampleRate,
ciamo della sessione audio. Nel nostro caso, in- &size, ...
dichiamo che vogliamo sia registrare sia fare il ...&hwSampleRate), “couldn’t get hw sample rate”);
play audio.
Attiviamo ora l’audio session con i parametri im-
UInt32 audioCategory = kAudioSessionCategory_PlayAn-
dRecord; postati.
XThrowIfError(AudioSessionSetProperty(kAudioSession
Property_AudioCategory, ... XThrowIfError(AudioSessionSetActive(true), “couldn’t set
...sizeof(audioCategory), &audioCategory), “couldn’t set ...
audio category”); ...audio session active\n”);

Poichè abbiamo bisogno di intercettare even- La funzione -


” ed è op-
nostro caso, se viene collegato un auricolare portuno vederla internamente perché completa
oppure se il telefono viene agganciato al dock, la descrizione del lavoro di inizializzazione. Alla
indichiamo quale funzione di callback richiama- funzione passiamo:
re in caso di variazione. , che verrà popolata dalla funzione
con la
XThrowIfError(AudioSessionAddPropertyListener... la struttura , che abbiamo visto
...(kAudioSessionProperty_AudioRouteChange, propLi-
stener, self), ... la , che verrà popolata dalla fun-
...”couldn’t set property listener”); zione con il formato audio di uscita.

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {43

XThrowIfError(SetupRemoteIO(rioUnit, inputProc, thru- UInt32 one = 1;


Format), “couldn’t ...
...setup remote i/o unit”); XThrowIfError(AudioUnitSetProperty(inRemoteIOUnit,
...
La funzione si occupa di trovare un componen- ...kAudioOutputUnitProperty_EnableIO, kAudioUnitSco-
te di input audio adatto fra quelli conosciuti dal pe_Input, 1, &one, ...
...sizeof(one)), “couldn’t enable input on the remote I/O
dispositivo.
unit”);

int SetupRemoteIO (AudioUnit& inRemoteIOUnit, AU-


RenderCallbackStruct ... Ora dobbiamo indicare a quale funzione di call-
...inRenderProc, CAStreamBasicDescription& outFormat)
back vogliamo che siano inviati i dati provenien-

Nella AudioComponentDescription andiamo ad ti dal microfono. Qui andiamo ad utilizzare la

indicare le caratteristiche dell’unità audio che in- struttura che abbiamo descritto all’inizio e che

tendiamo ricercare promuove il metodo “ ”.

// Open the output unit XThrowIfError(AudioUnitSetProperty(inRemoteIOUnit,


AudioComponentDescription desc; ...
desc.componentType = kAudioUnitType_Output; ...kAudioUnitProperty_SetRenderCallback, kAudioU-
desc.componentSubType = kAudioUnitSubType_Remo- nitScope_Input, ...
teIO; ...0, &inRenderProc, sizeof(inRenderProc)), “couldn’t set
desc.componentManufacturer = kAudioUnitManufactu- remote ...
rer_Apple; ...i/o render callback”);
desc.componentFlags = 0;
desc.componentFlagsMask = 0;

Cerchiamo la prima unità audio disponibile con indicare il formato audio che ci aspettiamo. Nel

le caratteristiche indicate e la apriamo


kHz e, da notare, NativeEndian che ci sarà utile
AudioComponent comp =
AudioComponentFindNext(NULL, &desc); fase di visualizzazione dei dati più avanti.

XThrowIfError(AudioComponentInstanceNew(comp, // set our required format - LPCM non-interleaved 32 bit


&inRemoteIOUnit), ... floating point
...”couldn’t open the remote I/O unit”);
outFormat = CAStreamBasicDescription(44100, kAudio-
Indichiamo poi che sull’unità audio intendiamo FormatLinearPCM, 4, 1, ...
anche abilitare l’input microfonico, mettendo ad ...4, 2, 32, kAudioFormatFlagsNativeEndian | kAudioFor-
matFlagIsPacked | ...
1 la relativa proprietà.
...kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonIn-
terleaved);

Con l’istruzione precedente abbiamo costrui-


to la struttura dati che descrive il formato. Ora

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {44

andiamo ad applicarla all’AudioUnit impostando


drawFormat.SetAUCanonical(2, false);
sia la proprietà di ingresso sia quella del forma- drawFormat.mSampleRate = 44100;
to di uscita.
XThrowIfError(AudioConverterNew(&thruFormat,
XThrowIfError(AudioUnitSetProperty(inRemoteIOUnit, &drawFormat, &audioConverter),...
... ...”couldn’tsetup AudioConverter”);
...kAudioUnitProperty_StreamFormat, kAudioUnitSco-
pe_Input, 0, ...
...&outFormat, sizeof(outFormat)), “couldn’t set the remote
I/O ...
...unit’s output client format”);
-
XThrowIfError(AudioUnitSetProperty(inRemoteIOUnit,
”.
...
...kAudioUnitProperty_StreamFormat, kAudioUnitSco- dcFilter = new DCRejectionFilter[thruFormat.Number-
pe_Output, 1, &outFormat, ... Channels()];
...sizeof(outFormat)), “couldn’t set the remote I/O unit’s
input ... Una volta impostato tutto, abbiamo bisogno di
...client format”); costruire i nostri buffer. Precedentemente ab-
biamo indicato in il nostro buf-
-
fer. Ma questo valore deve diventare, sulla base
lizzare la .
delle caratteristiche dell’hardware del dispositi-
XThrowIfError(AudioUnitInitialize(inRemoteIOUnit), vo e delle nostre indicazioni, una dimensione in
“couldn’t initialize ... bytes.
...the remote I/O unit”);

-
e continuiamo con l’ini-
zializzazione della porzione audio. La struttura
alla base della variabile è di tipo
dichiarata negli
.
E’ importante sottolineare che questa struttura
Leggiamo quindi il valore del numero di campio-
è in tutto e per tutto uguale come impronta alla
ni per ogni fettina di tempo
struttura ma
aggiunge una serie di funzioni ed impostazioni UInt32 maxFPS;
utili per non “impazzire” con la strutturazione dei size = sizeof(maxFPS);
byte imposta dal processore sottostante. E par-
lo principalmente del simulatore iOS. XThrowIfError(AudioUnitGetProperty(rioUnit, ...
...kAudioUnitProperty_MaximumFramesPerSlice, kAu-
Le due istruzioni indicano che l’audio converter
dioUnitScope_Global, 0, ...
che andremo a creare avrà un output a 44100 ...&maxFPS, &size), “couldn’t get the remote I/O unit’s max
kHz, 2 canali non intervallati. frames per slice”);

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {45

E con questo valore andiamo a costruire i nostri


XThrowIfError(AudioOutputUnitStart(rioUnit), “couldn’t
buffer. L’esempio di buffer ne ha diversi, perché
start remote i/o unit”);
vengono utilizzati anche per gestire il bell’effetto
di evanescenza tipico degli oscilloscopi. size = sizeof(thruFormat);

XThrowIfError(AudioUnitGetProperty(rioUnit, kAudioU-
nitProperty_StreamFormat, ...
I buffer di ingresso ed uscita della trasformata
...kAudioUnitScope_Output, 1, &thruFormat, &size),
di Fourier “couldn’t get the remote ...
...I/O unit’s output client format”);
fftBufferManager = new FFTBufferManager(maxFPS);

l_fftData = new int32_t[maxFPS/2];


unitIsRunning = 1;

I buffer per la gestione della visualizzazione dati


e dell’effetto evanescenza Wow!! Dopo questa bella avventura, abbiamo

drawABL = (AudioBufferList*) audio aperta. Benedetto il dono della sintesi!!


malloc(sizeof(AudioBufferList) + ... Come avete visto, ho decisamente bypassato
...sizeof(AudioBuffer));
-
cia utente. Anche perché veramente sposta di
drawABL->mNumberBuffers = 2;
molto l’attenzione da quello che veramente è di
interesse. Ci torneremo comunque con la se-
for (UInt32 i=0; i<drawABL->mNumberBuffers; ++i) conda parte.

{
drawABL->mBuffers[i].mData = (SInt32*)
calloc(maxFPS, sizeof(SInt32));
drawABL->mBuffers[i].mDataByteSize = maxFPS *
sizeof(SInt32);
drawABL->mBuffers[i].mNumberChannels = 1;
}

Il buffer per la visualizzazione della linea dell’o-


scilloscopio

oscilLine = (GLfloat*)malloc(drawBufferLen * 2 * Ricezione del flusso audio


sizeof(GLfloat));

-
leggiamo il formato di stream. indicando il metodo statico come
. In questo metodo passeranno tutti i
campioni registrati dal microfono e qui saremo
pronti a gestirli.

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {46

static OSStatus PerformThru(


// Remove DC component
void *inRefCon, for(UInt32 i = 0; i < ioData->mNumberBuffers; ++i)
AudioUnitRenderActionFlags *ioActionFlags, THIS->dcFilter[i].InplaceFilter((Float32*)
const AudioTimeStamp *inTimeStamp, (ioData->mBuffers[i].mData), inNumberFrames);
UInt32 inBusNumber, ...
UInt32 inNumberFrames,
AudioBufferList *ioData) Poiché è nostro interesse (mio e vostro, se siete

Per prima cosa, ci ricolleghiamo all’istanza di funzioni di trasformazione di Fourier, dimenti-

delegato originale. Ricordate che questo codice chiamoci, per ora, del codice che elabora di dati

viene richiamato come e quindi è nel per la schermata oscilloscopio. Passiamo diret-

codice gestito dal , con un tamente alla else che richiama le funzioni per

. Con questa prima, semplice istruzio- la modalità Spettro e FFT (che sono foraggiate

ne il puntatore è certamente l’istanza di dagli stessi dati).

classe che abbiamo indicato come ospite della Nell’istanza del delegato, in fase di inizializza-

. zione, abbiamo anche creato una istanza -


, contenuto nell’omonimo
aurioTouchAppDelegate *THIS = (aurioTouchAppDelega-
te *)inRefCon;
al buffer creato per poter lavorare sui dati. Se

Come seconda cosa, richiediamo all’ il buffer non è stato istanziato, usciamo senza

di restituirci il blocco corretto di dati, effettuando clamore (shh...!!).

le dovute trasformazioni, se necessarie. Notate La funzione interrogata nella successiva ha

che stiamo passando le medesime informazio- lo scopo di indicare se il buffer interno della

ni che abbiamo ricevuto ma questa istruzione è è pieno (e quindi elaborabi-

necessaria per la corretta gestione del Bus di le) oppure no. Nel caso non lo sia, si continua a

collegamento fra l’unità audio e la no- riempirlo con i nuovi .

stra applicazione. ...


else if ((THIS->displayMode == aurioTouchDisplayMode-
OSStatus err = AudioUnitRender(THIS->rioUnit, ioAc-
Spectrum) ||
tionFlags, inTimeStamp, ...
(THIS->displayMode == aurioTouchDisplayModeO-
...1, inNumberFrames, ioData);
scilloscopeFFT))
{
if (err) { printf(“PerformThru: error %d\n”, (int)err); return
if (THIS->fftBufferManager == NULL) return noErr;
err; }

if (THIS->fftBufferManager->NeedsNewAudioData())
Nella fase di inizializzazione avevamo definito un filtro
THIS->fftBufferManager->GrabAudioData(ioData);
passa-alto; ...
}
...ora andiamo a farlo girare su tutti i buffer presenti nella
AudioBufferList ...
...ottenuta come risultato dell’istruzione precedente. Giungiamo quindi alla funzione più interessan-
te, ora che abbiamo il buffer pieno di dati da

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {47

elaborare. Lo passiamo alla funzione GrabAu-

semplicemente, lo aggiunge nel buffer locale


della classe.

void FFTBufferManager::GrabAudioData(AudioBufferLi
st *inBL)

{
if (mAudioBufferSize < inBL->mBuffers[0].mDataByte-
Size) return;

UInt32 bytesToCopy = min(inBL->mBuffers[0].mDa- Calcolo della Trasformata di Fourier


taByteSize,
mAudioBufferSize - mAudioBufferCurrentIndex); trasformazione. Ed il motivo è sempre l’ambito
memcpy(mAudioBuffer+mAudioBufferCurrentIndex,
nel quale siamo. In una callback è quasi sempre
inBL->mBuffers[0].mData, bytesToCopy);
opportuno prendersi i dati e rilasciare appena
mAudioBufferCurrentIndex += bytesToCopy / possibile il controllo al chiamante.
sizeof(Float32); In questa parte vi chiedo, per non entrare troppo
if (mAudioBufferCurrentIndex >= mAudioBufferSize / nel merito dell’interfaccia utente, di fare un pic-
sizeof(Float32)) colo salto con l’immaginazione.
{
Immaginate quindi che tutta l’interfaccia utente
OSAtomicIncrement32Barrier(&mHasAudioData);
OSAtomicDecrement32Barrier(&mNeedsAudioDa del nostro oscilloscopio sia in un -
ta); ospitato in una e denominato EA-
} . E infatti, molto del codice nel delegato
} serve proprio per la costruzione di questa user
interface.
La funzione si occupa anche di attivare op- Lo stesso è anche
delegato per la e deve implementa-
e quando il -
buffer è pieno. Per chi non le conoscesse, sono minato :
molto simpatiche le funzioni - Proprio dentro questo metodo troviamo il codice
e la relativa controparte. Effet- dove tutto rientra in gioco.
tuano un incremento o decremento del valore
- (void)drawView:(id)sender forTime:(NSTimeInterval)
indicato (come ++ e --) ma sono assolutamente time
sicure in ambiente multi-threading e multi-pro-
cessore. {
Nel nostro caso, lo ricordo nuovamente, siamo if ((displayMode == aurioTouchDisplayModeOscillo-
scopeWaveform)
ancora nell’ambito della callback che ha ricevu-
|| (displayMode == aurioTouchDisplayModeOscillosco-
to i dati dalla . peFFT))
{

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {48

sizione dispari. Così, un vettore composto da


if (!initted_oscilloscope) [self setupViewForOscillosco-
pe]; viene riorganizzato in un vettore
[self drawOscilloscope]; .
...
// Generate a split complex vector from the real data
La parte che ci interessa è nel -
vDSP_ctoz((COMPLEX *)mAudioBuffer, 2, &mDspSplit-
pe dove è presente la chiamata al calcolo della
Complex, 1, mFFTLength);
trasformata.
Il vettore complesso è ora inviato alla routine
if (displayMode == aurioTouchDisplayModeOscillosco-
di calcolo della trasformata che
peFFT)
prende in ingresso anche
{ valorizzata nel costruttore di classe ed obbliga-
if (fftBufferManager->HasNewAudioData()) toria prima di richiamare la trasformata. La dire-
{ zione della trasformata è verso il dominio delle
if (fftBufferManager->ComputeFFT(l_fftData))
frequenze ( ).
[self setFFTData:l_fftData
length:fftBufferManager->GetNumberFrames() /
2]; //Take the fft and scale appropriately

vDSP_fft_zrip(mSpectrumAnalysis, &mDspSplitComplex,
Se il buffer è pieno di campioni audio, viene ri-
1,
chiamata la funzione passando
mLog2N, kFFTDirection_Forward);
come parametro il puntatore al buffer predispo-
sto per ricevere il risultato della trasformata di Il risultato è un vettore complesso ed impac-
Fourier. chettato così organizzato:
E’ quindi ora di vedere come, in iOS, si calcola .
la trasformata di Fourier con le librerie dell’Ac- In questi abbiamo:
celerateFramework. La funzione è nuovamente i valori DC e NY sono i componenti DC e

Boolean FFTBufferManager::ComputeFFT(int32_t l’Array C sono i valori complessi in rappre-


*outFFTData) sentazione splittata.
Questo viene poi scalato e normalizzato nel vet-
{ tore complesso, perché la funzione di trasfor-
if (HasNewAudioData())
mazione introduce una moltiplicazione pari alla
{
dimensione dell’array.
Prima di poter procedere con il calcolo della tra- vDSP_vsmul(mDspSplitComplex.realp, 1, &mFFT-
sformata vera e propria, è necessario riorganiz- NormFactor,
zare il vettore di dati con il metodo .
mDspSplitComplex.realp, 1, mFFTLength);
In pratica andiamo a riorganizzare il vettore af-
vDSP_vsmul(mDspSplitComplex.imagp, 1, &mFFT-
-
NormFactor,
zione pari ed a seguire tutti gli elementi di po- mDspSplitComplex.imagp, 1, mFFTLength);

ELETTRONICA OPEN SOURCE


Come sviluppare un oscilloscopio su iPhone e dominare la FFT {49

valori interi per ritornare un array semplice da


// Zero out the nyquist value lavorare al metodo chiamante.
mDspSplitComplex.imagp[0] = 0.0;
//Convert floating point data to integer (Q7.24)
Il più è fatto, ora si tratta di applicare alcune vDSP_vsmul(tmpData, 1, &m24BitFracScale, tmpData, 1,
mFFTLength);
trasformazioni, ben indicate nei commenti, che
- for(UInt32 i=0; i<mFFTLength; ++i)
cessivo dei dati. outFFTData[i] = (SInt32) tmpData[i];
Per prima cosa calcoliamo la magnitudine qua-
drata di ogni elemento e mettiamo il risultato Per questa prima parte ci fermiamo qui, ma cre-
nell’array tmpData. do che tanto lavoro sia stato fatto e che ora mol-
te basi siano state acquisite. Con la prossima
// Convert the fft data to dB parte vedremo come viene gestita l’interfaccia
utente e tutta la porzione di codice relativo alla
Float32 tmpData[mFFTLength];
vDSP_zvmags(&mDspSplitComplex, 1, tmpData, 1, gestione dei dati per la visualizzazione oscillo-
mFFTLength);
Nel frattempo, sappiate che sono a disposizione
Poi impostiamo un fattore di aggiustamento in nei commenti per eventuali dubbi o domande.
modo che il valore più basso dell’array corri-
sponda a

// In order to avoid taking log10 of zero, an adjusting factor


is
added in to make the minimum value equal -128dB
vDSP_vsadd(tmpData, 1, &mAdjust0DB, tmpData, 1,
mFFTLength);

con l’apposita
funzione.

Float32 one = 1;

vDSP_vdbcon(tmpData, 1, &one, tmpData, 1,


mFFTLength, 0);

Come ultima operazione, moltiplichiamo tutti i


valori per una costante e convertiamo il tutto in

ELETTRONICA OPEN SOURCE


Continua a leggere la seconda parte dell’articolo  e  scopri  come  generare i codici
relativi al rendering  dell’interfaccia  utente  e  sfruttare  l’accelerazione  hardware  per  
il disegno in real-time  delle  linee  dell’oscilloscopio.

Clicca sul link e abbonati!

Per poter leggere l'articolo online e interagire con gli autori sempre a tua
disposizione per ogni approfondimento è necessario abbonarsi! Usufruisci del
coupon sconto del 20% (CODICE SCONTO MAG20) che sarà valido fino a lunedì 5
Maggio.

Visita il sito dedicato agli abbonati

Abbonati ora!

Potrebbero piacerti anche