如何将旋律提取算法的音高轨迹转换为哼唱音频信号
作为一个在家进行有趣研究项目的一部分,我正在尝试找到一种方法,将一首歌转换成类似哼唱的音频信号(也就是我们听歌时感知到的旋律)。在进一步描述我在这个问题上的尝试之前,我想提一下,我对音频分析完全是个新手,虽然我在分析图像和视频方面有很多经验。
我在网上查了一下,发现了一些旋律提取的算法。给定一段多声道的音频信号(比如 .wav 文件),这些算法会输出一个音高轨迹——在每个时间点,它们会估计出主导音高(来自歌手的声音或某种旋律生成乐器),并跟踪这个主导音高随时间的变化。
我读了一些论文,发现这些算法似乎会对歌曲进行短时傅里叶变换,然后对频谱图进行一些分析,以获取和跟踪主导音高。旋律提取只是我想开发的系统中的一个组成部分,所以只要算法能在我的音频文件上表现得不错,并且代码是可以获取的,我都不介意使用任何可用的算法。由于我对这个领域还很陌生,我很乐意听听大家有什么建议,哪些算法效果好,以及在哪里可以找到它们的代码。
我找到两种算法:
我选择了 Melodia,因为它在不同音乐风格上的结果看起来相当不错。请查看 这个链接,看看它的结果。你在每段音乐中听到的哼唱,正是我感兴趣的部分。
“我希望你们能帮我解决的问题是,如何为任意歌曲生成这种哼唱。”
这个算法(作为 vamp 插件提供)会输出一个音高轨迹——[时间戳,音高/频率]——一个 Nx2 的矩阵,第一列是时间戳(以秒为单位),第二列是对应时间戳下检测到的主导音高。下面是从算法获得的音高轨迹的可视化,紫色部分覆盖在歌曲的时域信号(上方)和它的频谱图/短时傅里叶变换上。音高/频率的负值表示算法对无声/非旋律段的主导音高估计。因此,所有大于等于 0 的音高估计对应于旋律,其余的对我来说并不重要。
现在我想把这个音高轨迹转换回类似哼唱的音频信号——就像作者在他们的网站上展示的那样。
下面是我写的一个 MATLAB 函数,用来实现这个功能:
function [melSignal] = melody2audio(melody, varargin)
% melSignal = melody2audio(melody, Fs, synthtype)
% melSignal = melody2audio(melody, Fs)
% melSignal = melody2audio(melody)
%
% Convert melody/pitch-track to a time-domain signal
%
% Inputs:
%
% melody - [time-stamp, dominant-frequency]
% an Nx2 matrix with time-stamp in the
% first column and the detected dominant
% frequency at corresponding time-stamp
% in the second column.
%
% synthtype - string to choose synthesis method
% passed to synth function in synth.m
% current choices are: 'fm', 'sine' or 'saw'
% default='fm'
%
% Fs - sampling frequency in Hz
% default = 44.1e3
%
% Output:
%
% melSignal -- time-domain representation of the
% melody. When you play this, you
% are supposed to hear a humming
% of the input melody/pitch-track
%
p = inputParser;
p.addRequired('melody', @isnumeric);
p.addParamValue('Fs', 44100, @(x) isnumeric(x) && isscalar(x));
p.addParamValue('synthtype', 'fm', @(x) ismember(x, {'fm', 'sine', 'saw'}));
p.addParamValue('amp', 60/127, @(x) isnumeric(x) && isscalar(x));
p.parse(melody, varargin{:});
parameters = p.Results;
% get parameter values
Fs = parameters.Fs;
synthtype = parameters.synthtype;
amp = parameters.amp;
% generate melody
numTimePoints = size(melody,1);
endtime = melody(end,1);
melSignal = zeros(1, ceil(endtime*Fs));
h = waitbar(0, 'Generating Melody Audio' );
for i = 1:numTimePoints
% frequency
freq = max(0, melody(i,2));
% duration
if i > 1
n1 = floor(melody(i-1,1)*Fs)+1;
dur = melody(i,1) - melody(i-1,1);
else
n1 = 1;
dur = melody(i,1);
end
% synthesize/generate signal of given freq
sig = synth(freq, dur, amp, Fs, synthtype);
N = length(sig);
% augment note to whole signal
melSignal(n1:n1+N-1) = melSignal(n1:n1+N-1) + reshape(sig,1,[]);
% update status
waitbar(i/size(melody,1));
end
close(h);
end
这个代码的基本逻辑是这样的:在每个时间戳,我合成一个短暂的波形(比如正弦波),它的频率等于在该时间戳检测到的主导音高/频率,持续时间等于输入旋律矩阵中与下一个时间戳的间隔。我只是想知道这样做是否正确。
然后,我将这个函数生成的音频信号与原始歌曲一起播放(旋律在左声道,原始歌曲在右声道)。虽然生成的音频信号似乎能很好地分离出旋律生成的声音(如人声/主乐器)——在有声音的地方是活跃的,而在其他地方是零——但信号本身远不是哼唱(我得到的声音像是“嘟嘟嘟嘟嘟嘟嘟嘟”),与作者在他们网站上展示的效果相差甚远。具体来说,下面是一个可视化,显示了输入歌曲的时域信号在底部,以及我用函数生成的旋律的时域信号。
一个主要问题是——虽然我在每个时间戳都知道要生成的波的频率和持续时间,但我不知道如何设置波的振幅。目前,我将振幅设置为一个固定值,我怀疑这就是问题所在。
有没有人对此有什么建议?我欢迎任何编程语言的建议(最好是 MATLAB、Python、C++),但我想我的问题更普遍——如何在每个时间戳生成波形?
我脑海中有几个想法/解决方案:
- 通过从原始歌曲的时域信号中获取平均值/最大值来设置振幅。
- 完全改变我的方法——计算歌曲音频信号的频谱图/短时傅里叶变换。强行/零掉或轻柔地去掉所有其他频率,只保留与我的音高轨迹相近的频率。然后计算逆短时傅里叶变换,以获得时域信号。
4 个回答
你遇到了至少两个问题。
首先,正如你所猜测的,你的分析方法丢掉了原始频谱中旋律部分的所有音量信息。你需要一个能够捕捉这些信息的算法,而不仅仅是处理整个信号的音量,或者只是处理自然音乐声音的FFT音高区间。这是一个不简单的问题,介于旋律音高提取和盲源分离之间。
其次,声音有音色,包括泛音和包络,即使在一个固定的频率下也是如此。你现在的合成方法只是在产生一个单一的正弦波,而哼唱可能会产生一堆更有趣的泛音,包括很多比音高更高的频率。为了让声音听起来更自然,你可以尝试分析自己哼唱一个音高的频谱,然后试着重建那些几十个泛音的正弦波,而不是只生成一个,每个波的音量要适当。你还可以观察自己哼唱一个短音符时音量随时间变化的包络,并用这个包络来调节合成器的音量。
如果我理解得没错,你似乎已经有了准确的音高表示,但你遇到的问题是你生成的声音听起来“还不够好”。
先说说你的第二种方法:只保留音高而过滤掉其他内容,这样做不会有什么好结果。因为如果你只保留与音高相关的几个频率,输入信号的质感就会丢失,这正是让声音听起来好的原因。实际上,如果你极端一点,只保留与音高对应的一个样本,然后进行反傅里叶变换,你得到的就会是一个正弦波,这正是你现在的做法。如果你真的想这样做,我建议你直接对时间信号应用一个滤波器,而不是频繁地在频域和时域之间切换,这样会更麻烦且耗费资源。这个滤波器会在你想保留的频率附近有一个小的截止频率,这样可以让声音的质感更好。
不过,如果你已经有了满意的音高和时长估计,但想改善声音的表现,我建议你用一些真实的哼唱(或者小提琴、长笛等你喜欢的乐器)样本来替换你的正弦波——无论你怎么调整,正弦波听起来总是像傻傻的“哔哔”声。对于音阶中的每个频率,你可以准备一些哼唱样本。如果内存有限,或者你表示的歌曲不在标准音阶内(比如中东音乐),你可以只为几个频率准备哼唱样本。然后,你可以通过从这些哼唱样本进行采样率转换,得到任何频率的哼唱声音。准备几个样本进行采样转换,可以让你选择与需要生成的频率“最佳”匹配的样本,因为采样转换的复杂性取决于这个比例。显然,添加采样率转换会比仅仅拥有一组样本要复杂和耗费计算资源。
使用真实样本的库会大大提高你生成声音的质量。它还可以让你在每个新音符上有更真实的起音效果。
然后,正如你所建议的,你可能还想通过跟踪输入信号的瞬时幅度来调整音量,以产生更细腻的声音表现。
最后,我建议你也可以调整你的时长估计,以便让不同声音之间的过渡更平滑。从你让我很享受的音频文件(哔哔哔哔哔)和你展示的图表来看,似乎在你歌曲的表现中插入了很多中断。你可以通过延长时长估计,去掉任何短于0.1秒的静音,来避免这种情况。这样,你就能保留原歌曲中的真实静音,同时避免切断每个音符。
虽然我不能直接看到你的 synth() 函数,但根据它的参数,我觉得你的问题可能是没有处理好相位。
也就是说,单单把波形片段拼接在一起是不够的,你必须确保它们的相位是连续的。否则,每次拼接两个波形片段时,你都会在波形中制造一个不连续的地方。如果是这样的话,我猜你听到的频率总是一样的,而且听起来更像锯齿波,而不是正弦波——我说得对吗?
解决办法是把第 n 个片段的起始相位设置为第 n-1 个片段的结束相位。下面是一个例子,展示如何在不产生相位不连续的情况下拼接两个不同频率的波形:
fs = 44100; % sampling frequency
% synthesize a cosine waveform with frequency f1 and starting additional phase p1
p1 = 0;
dur1 = 1;
t1 = 0:1/fs:dur1;
x1(1:length(t1)) = 0.5*cos(2*pi*f1*t1 + p1);
% Compute the phase at the end of the waveform
p2 = mod(2*pi*f1*dur1 + p1,2*pi);
dur2 = 1;
t2 = 0:1/fs:dur2;
x2(1:length(t2)) = 0.5*cos(2*pi*f2*t2 + p2); % use p2 so that the phase is continuous!
x3 = [x1 x2]; % this should give you a waveform without any discontinuities
需要注意的是,虽然这样可以得到一个连续的波形,但频率的变化是瞬间的。如果你想让频率在 time_n 到 time_n+1 之间逐渐变化,那就需要用到更复杂的技术,比如 McAulay-Quatieri 插值。不过,如果你的片段足够短,这样的效果应该就足够好了。
关于其他评论,如果我理解得没错,你的目标只是想听到频率的变化,而不是让它听起来像原始声音。在这种情况下,音量并不是特别重要,你可以保持它固定。
如果你想让它听起来像原始声音,那就是另一个话题了,可能超出了我们这次讨论的范围。
希望这些能帮到你!