Phase continuous transitions can be accomplished by implementing the waveform in phase versus time instead of frequency versus time. Frequency is the time derivative of phase, so instead of stepping between two frequency values with a frequency vs time waveform, the phase vs time waveform would be ramps with the slope of the ramp setting the desired frequency. Note that a numerically controlled oscillator (NCO) does this directly and is simply a phase accumulator followed by a look-up table as one approach to compute the trigonometric $\sin(\theta)$ or $\cos(\theta)$ computation (or both for a complex $e^{j\theta}$ output!), converting instantaneous phase to a sinusoid. The rate of accumulation is set by a Frequency Control Word which sets the ramp rate of the phase versus time. In typical NCO implementations, the phase rolls over as a cyclical counter at $2\pi$ which is of no consequence to phase continuity in a sine wave and prevents any overflow issues. The complete details for a numerically controlled oscillator implementation are given here.
The NCO is ideal for this application as it is relatively simple in implementation and will naturally maintain phase continuity at any given change in frequency. Additionally we can implement arbitrary frequency modulation, and it can be easily adapted for a direct phase and amplitude modulation as well as explained in the referenced link. An example NCO implementation in Python is as follows, fcw is the frequency as an vector that update on every sample, thus we can change the frequency to any arbitrary value at any time; sr is the sample rate:
import numpy as np
def nco(fcw, sr):
phase = 0
phase_result = []
for fcw_samp in fcw:
ph_step = 2*np.pi* fcw_samp * 1/sr
phase += ph_step
phase_result.append(phase)
return np.cos(phase_result)
And demonstrating the OP's example:
sr = 22050
ts = 1.0/sr
duration = 2
t = np.arange(0,duration,ts)
freq1 =4.20
freq2 = 6.66
t_change = 1
fcw = np.zeros_like(t)
fcw[t<=t_change] = freq1
fcw[t>t_change] = freq2
result = nco(fcw, sr)
Results in the following plot:
The amplitude itself can be changed completely independently with a similar “Amplitude Control Word” which scales its output. This can be tapered as desired to avoid abrupt transitions. The taper can be in the vector containing the amplitudes for each sample (similar to the vector of frequency control words above), or the resulting signal can be passed through an FIR low pass filter with the cutoff set above the highest frequency desired.
Below shows an example of this with the python NCO function modified to support amplitude control:
def nco(fcw, acw, sr):
# fcw: array-like container of frequency control values, one for each sample
# acw: array-like container of amplitude control values, one for each sample
# sr: sample rate
phase = 0
phase_result = []
for fcw_samp in fcw:
ph_step = 2*np.pi* fcw_samp * 1/sr
phase += ph_step
phase_result.append(phase)
return acw * np.cos(phase_result)
And demonstrating with the OP's example where a Gaussian filtered transition was use to change the amplitude:
import scipy.signal as sig
sr = 22050
ts = 1.0/sr
duration = 2
t = np.arange(0,duration,ts)
freq1 =4.20
freq2 = 6.66
amp1 = 0.75
amp2 = 1
t_change = 1
taper_dur = 0.2
b=10
fcw = np.zeros_like(t)
acw = np.zeros_like(t)
fcw[t<=t_change] = freq1
fcw[t>t_change] = freq2
acw[t<=t_change] = amp1
acw[t>t_change] = amp2
nsamps = int(taper_dur * sr) * 2
coeff = sig.gaussian(nsamps, 500)
acw2 = sig.filtfilt(coeff, np.sum(coeff), acw)
result = nco(fcw, acw2, sr)
Resulting in the following plot:
Mathematical Description of Changing Frequency with Continuous Phase
Below provides a complete mathematical relationship for precomputing the phase waveforms needed for frequency change with continuous phase.
Specific to the OP's example, the first phase ramp is given by $2\pi 4.20$ radians/sec starting at $t=0$ and transitions to $2\pi 6.66$ radians per second at $t=t_1$.
This makes it then very easy to keep the phase continuous at the transitions by simply accumulating the phase as given by the desired slope (which is the target frequency), similar to the operation of the NCO. Note in this case, the offset is given by:
$$\theta_{offset} = \theta(t_1) - 2\pi f_2 t_1 = 2\pi f_1 t_1 - 2\pi f_2 t_1$$
From the resulting phase ramp we can plot the sine or cosine of that phase versus time as desired and it will be phase continuous.
This is demonstrated in Python below with the following results:
Python:
import matplotlib.pyplot as plt
import numpy as np
sr = 22050
ts = 1.0/sr
duration = 2
t = np.arange(0,duration,ts)
t_change = 1
freq1 =4.20
phase1 = 2*np.pi*freq1* t[t<=t_change]
freq2 = 6.66
phase2 = 2*np.pi*freq2*t[t>t_change] + 2*np.pi*(freq1-freq2)*t_change
total_phase = np.concatenate((phase1, phase2))
out = np.cos(total_phase)
Considering more generalized cases with multiple steps, the phase offset to add for any given segment is:
$$\theta_n = \sum_{m=1}^{n-1}2\pi f_m t_m - \sum_{m=1}^{n-1}2\pi f_{m+1} t_m \tag{1} \label{1}$$
With the resulting phase ramp proceeding starting at $t_n$ as:
$$\theta(t) = 2\pi f_n t + \theta_n$$
This is demonstrated with a 3 segment ramp with reference to the figure below, where we note that each next phase ramp will start at the completed phase from the previous frequency. For example, the second phase offset given as $\theta_2$ below is similarly determined as above by solving for the intersection of linear line equations to be:
$$\theta_2 = \theta(t_2)-2\pi f_3 t_2$$
$$= 2\pi f_2 t_2 + \theta_1 - 2\pi f_3 t_2$$
$$ = 2\pi f_2 t_2 + 2\pi (f_1-f_2)t_1 - 2\pi f_3 t_2$$
Which is consistent with the generalized form given in $\ref{1}$ as:
$$ \theta_2 = 2\pi f_1 t_1 + 2\pi f_2 t_2 -2\pi f_2 t_1 - 2\pi f_3 t_2$$
With this, the third segment plotted below is given as:
$$\theta(t) = 2\pi f_3 t + \theta_2$$
Summary:
In summary I have given two approaches to creating continuous phase frequency transitions: as an NCO which automatically maintains phase continuity through frequency transitions, and a computationally based approach for use when the formula for the phase waveform is desired.