├── LICENSE ├── README.md ├── randomizeOnsets.lua ├── notesToTextGrid.lua ├── loadEnvelope.lua ├── shift.lua ├── loadPitch.lua ├── splitNote.lua ├── growl.lua ├── quantizePitch.lua ├── filterPitch.lua └── notesFromTextGrid.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 hataori@protonmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # real-voice 2 | Scripts for working with a real voice in Synthesizer V Studio Pro 3 | 4 | ## What is this good for 5 | Usually "tuning" a voice in a vocal synthesizer means drawing some pitch curve over MIDI notes. The timing of the notes is seldom changed. 6 | 7 | I wanted to do a different thing - get the pitch curve from a real singer and change the timing to fit it. The timing means the lenghts of phonemes and pauses. 8 | This way it also should sound better and it does - after all, it is partly from a real person. I call it "Voice Copy". It is inaccurate, copied are only some prosodic features. 9 | 10 | The scripts in this repository are designed to make the process easier. 11 | 12 | ## Installation 13 | If you want to use these scripts, you have to have Dreamtonics [Synthesizer V Studio Pro](https://dreamtonics.com/en/synthesizerv/), which enables scripting. 14 | Everything is tested only on Windows, but it should work on other platforms where Studio Pro is working. 15 | 16 | - Download this [zip archive](https://github.com/hataori-p/real-voice/archive/refs/heads/main.zip), 17 | - unzip it, 18 | - and copy/move whole folder real-voice-main to SynthV's scripts folder at path C:\\Users\\\\Documents\\Dreamtonics\\Synthesizer V Studio\\scripts\\ 19 | - You can open the scripts folder from MainMenu / Scripts / Open Scripts folder command and rename real-voice-main to whatever you want, eg. realVoice 20 | 21 | After starting SynthV Studio (or rescanning scripts) you should have these scripts in the Scripts Menu: 22 | - RV Filter Pitch (obsolete - use Praat's Convert / Smooth function) 23 | - RV Load Envelope (obsolete, create separate notes for consonants) 24 | - RV Load Pitch (update v3) 25 | - RV Notes from TextGrid (update v3, exclamation mark functionality removed) 26 | - RV Notes to TextGrid (v1) 27 | - RV Quantize Pitch (v2) 28 | - RV Randomize Onsets (v1) 29 | - RV Split Note (update v3) 30 | 31 | I recommend to set up a keyboard shortcuts for RV Load Pitch (I use alt-X) and for RV Split Note (alt-C) 32 | 33 | ## Other software needed 34 | You will also need [Praat](https://www.fon.hum.uva.nl/praat/) phonetic program installed and be able to run it. 35 | It is available for many platforms. 36 | 37 | ## Demo videos 38 | For the instructions how to use these scripts refer to my demonstration videos on Youtube 39 | [playlist](https://youtube.com/playlist?list=PLHA_yIumhQPDJ3PULhXeE-gypioT-eear) 40 | 41 | ## Known Issues 42 | Unable to load files whose paths contain any non-English characters (e.g. Chinese, Korean, Japanese). 43 | -------------------------------------------------------------------------------- /randomizeOnsets.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Randomize Onsets" 2 | 3 | local MIN_NOTE_LENGTH_B = SV:quarter2Blick(1/32) 4 | 5 | local inputForm = { 6 | title = SV:T("Randomize Onsets"), 7 | message = SV:T("Randomly shifts onsets of selected or all notes in current group"), 8 | buttons = "OkCancel", 9 | widgets = { 10 | { 11 | name = "sl", type = "Slider", 12 | label = "Amount of randomness (standard deviation in ms)", 13 | format = "%3.0f", 14 | minValue = 0, 15 | maxValue = 50, 16 | interval = 1, 17 | default = 25 18 | }, 19 | { 20 | name = "tb", type = "TextBox", 21 | label = "Random Seed (to have repeatable results)", 22 | default = "0" 23 | } 24 | } 25 | } 26 | 27 | function getClientInfo() 28 | return { 29 | name = SV:T(SCRIPT_TITLE), 30 | author = "Hataori@protonmail.com", 31 | category = "Real Voice", 32 | versionNumber = 1, 33 | minEditorVersion = 65537 34 | } 35 | end 36 | 37 | local function gaussian(mean, variance) 38 | return math.sqrt(-2 * variance * math.log(math.random())) * 39 | math.cos(2 * math.pi * math.random()) + mean 40 | end 41 | 42 | local function randInRange(mean, stdev, range) 43 | local var = stdev^2 44 | local x 45 | repeat 46 | x = gaussian(mean, var) 47 | until x >= - range and x <= range 48 | return x 49 | end 50 | 51 | function process() 52 | local dlgResult = SV:showCustomDialog(inputForm) 53 | if not dlgResult.status then return end -- cancel pressed 54 | 55 | local stdev = dlgResult.answers.sl 56 | local seed = tonumber(dlgResult.answers.tb) or 0 57 | 58 | local timeAxis = SV:getProject():getTimeAxis() 59 | local scope = SV:getMainEditor():getCurrentGroup() 60 | local group = scope:getTarget() 61 | 62 | math.randomseed(seed) 63 | 64 | local notes = {} -- notes indexes 65 | 66 | local noteCnt = group:getNumNotes() 67 | if noteCnt == 0 then -- no notes 68 | return 69 | else 70 | local selection = SV:getMainEditor():getSelection() 71 | local selectedNotes = selection:getSelectedNotes() 72 | if #selectedNotes == 0 then 73 | for i = 1, noteCnt do 74 | table.insert(notes, i) 75 | end 76 | else 77 | table.sort(selectedNotes, function(noteA, noteB) 78 | return noteA:getOnset() < noteB:getOnset() 79 | end) 80 | 81 | for _, n in ipairs(selectedNotes) do 82 | table.insert(notes, n:getIndexInParent()) 83 | end 84 | end 85 | end 86 | 87 | for _, i in ipairs(notes) do 88 | local note = group:getNote(i) 89 | 90 | local onset_b, nend_b = note:getOnset(), note:getEnd() 91 | 92 | local shift = randInRange(0, stdev, stdev) / 1000 -- ms -> sec 93 | local onset = timeAxis:getSecondsFromBlick(onset_b) 94 | local new_onset_b = timeAxis:getBlickFromSeconds(onset + shift) 95 | -- positive shift correction 96 | if new_onset_b > onset_b and (nend_b - new_onset_b) < MIN_NOTE_LENGTH_B then 97 | new_onset_b = nend_b - MIN_NOTE_LENGTH_B 98 | end 99 | 100 | local ni = note:getIndexInParent() 101 | if ni > 1 then -- not first note 102 | local p_note = group:getNote(ni - 1) -- previous note 103 | local p_onset_b, p_nend_b = p_note:getOnset(), p_note:getEnd() 104 | -- negative shift correction 105 | if new_onset_b < onset_b and (new_onset_b - p_onset_b) < MIN_NOTE_LENGTH_B then 106 | new_onset_b = p_onset_b + MIN_NOTE_LENGTH_B 107 | end 108 | 109 | if math.abs(onset_b - p_nend_b) < MIN_NOTE_LENGTH_B / 4 then -- 2 notes connected, change duration of previous 110 | p_note:setDuration(new_onset_b - p_onset_b) 111 | end 112 | end 113 | 114 | note:setTimeRange(new_onset_b, nend_b - new_onset_b) 115 | end 116 | end 117 | 118 | function main() 119 | process() 120 | SV:finish() 121 | end 122 | -------------------------------------------------------------------------------- /notesToTextGrid.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Notes to TextGrid" 2 | -- Ver.1 - exports notes and lyrics to Praat's textGrid object, pitch encoded in lyrics, 3 | -- after editing in Praat it can be loaded back by "RV Notes from TextGrid" ver.2 4 | 5 | function getClientInfo() 6 | return { 7 | name = SV:T(SCRIPT_TITLE), 8 | author = "Hataori@protonmail.com", 9 | category = "Real Voice", 10 | versionNumber = 1, 11 | minEditorVersion = 65537 12 | } 13 | end 14 | 15 | local inputForm = { 16 | title = SV:T("Notes to Praat Textgrid"), 17 | message = SV:T("Exports notes to a textgrid file"), 18 | buttons = "OkCancel", 19 | widgets = { 20 | { 21 | name = "file", type = "TextBox", 22 | label = SV:T("filePath/fileName.txt"), 23 | default = "[projectDir]/[projectName]_textGrid.txt" 24 | } 25 | } 26 | } 27 | 28 | local function getProjectPathName() 29 | local projectFileName = SV:getProject():getFileName() 30 | if not projectFileName then return end 31 | 32 | local projectName, projectDir 33 | projectFileName = projectFileName:gsub("\\", "/") 34 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 35 | if not projectDir or not projectName then error(SV:T("project dir or name not found")) end 36 | 37 | return projectName, projectDir 38 | end 39 | 40 | local function process() 41 | local dlgResult = SV:showCustomDialog(inputForm) 42 | if not dlgResult.status then return end -- cancel pressed 43 | -- output file 44 | local filePathName = dlgResult.answers.file or "" 45 | if filePathName == "" then filePathName = "[projectDir]/[projectName]_textGrid.txt" end 46 | 47 | if filePathName:match("%[projectDir%]") or filePathName:match("%[projectName%]") then 48 | local projectName, projectDir = getProjectPathName() 49 | filePathName = filePathName:gsub("%[projectDir%]", projectDir) 50 | filePathName = filePathName:gsub("%[projectName%]", projectName) 51 | end 52 | filePathName = filePathName:gsub("\\", "/") 53 | filePathName = filePathName:gsub("/+", "/") 54 | -- synthv structures 55 | local project = SV:getProject() 56 | local timeAxis = project:getTimeAxis() 57 | local scope = SV:getMainEditor():getCurrentGroup() 58 | local group = scope:getTarget() 59 | 60 | local notes, maxtime = {}, 0 61 | for i = 1, group:getNumNotes() do 62 | local note = group:getNote(i) 63 | 64 | local lyr = note:getLyrics() 65 | local pitch = note:getPitch() - 69 -- midi offset 66 | local blOnset, blEnd = note:getOnset(), note:getEnd() 67 | 68 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 69 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 70 | 71 | table.insert(notes, { 72 | lyr = lyr, 73 | pitch = pitch, 74 | tstart = tons, 75 | tend = tend 76 | }) 77 | 78 | if tend > maxtime then maxtime = tend end 79 | end 80 | maxtime = maxtime + 1.0 81 | -- number of intervals 82 | local cnt = 0 83 | local pretim = 0 84 | for _, nt in ipairs(notes) do 85 | if math.abs(nt.tstart - pretim) > 0.0001 then 86 | cnt = cnt + 1 87 | end 88 | cnt = cnt + 1 89 | 90 | pretim = nt.tend 91 | end 92 | cnt = cnt + 1 93 | -- write to file 94 | local fo = io.open(filePathName, "w") 95 | fo:write("File type = \"ooTextFile\"\n") 96 | fo:write("Object class = \"TextGrid\"\n") 97 | fo:write("\n") 98 | fo:write("0\n") 99 | fo:write(maxtime.."\n") 100 | fo:write("\n") 101 | fo:write("1\n") 102 | fo:write("\"IntervalTier\"\n") 103 | fo:write("\"Notes\"\n") 104 | fo:write("0\n") 105 | fo:write(maxtime.."\n") 106 | fo:write(cnt.."\n") 107 | 108 | local pretim = 0 109 | for _, nt in ipairs(notes) do 110 | if math.abs(nt.tstart - pretim) > 0.0001 then 111 | fo:write(pretim.."\n") 112 | fo:write(nt.tstart.."\n") 113 | fo:write("\"\"\n") 114 | 115 | fo:write(nt.tstart.."\n") 116 | fo:write(nt.tend.."\n") 117 | fo:write("\""..nt.lyr.." ("..nt.pitch..")\"\n") 118 | else 119 | fo:write((nt.tstart).."\n") 120 | fo:write(nt.tend.."\n") 121 | fo:write("\""..nt.lyr.." ("..nt.pitch..")\"\n") 122 | end 123 | 124 | pretim = nt.tend 125 | end 126 | 127 | fo:write(pretim.."\n") 128 | fo:write((pretim + 1.0).."\n") 129 | fo:write("\"\"\n") 130 | 131 | fo:close() 132 | end 133 | 134 | function main() 135 | process() 136 | SV:finish() 137 | end 138 | 139 | -------------------------------------------------------------------------------- /loadEnvelope.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Load Envelope" 2 | 3 | function getClientInfo() 4 | return { 5 | name = SV:T(SCRIPT_TITLE), 6 | author = "Hataori@protonmail.com", 7 | category = "Real Voice", 8 | versionNumber = 1, 9 | minEditorVersion = 65537 10 | } 11 | end 12 | 13 | function main() 14 | loadEnvelope() 15 | SV:finish() 16 | end 17 | 18 | ------------ Pitch 19 | local praatPitch = {} -- class 20 | 21 | local PitchHeader = { 22 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 23 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 24 | {t="del"}, 25 | {n="xmin", t="num"}, 26 | {n="xmax", t="num"}, 27 | {n="nx", t="num"}, 28 | {n="dx", t="num"}, 29 | {n="x1", t="num"}, 30 | {n="ceiling", t="num"}, 31 | {n="maxnCandidates", t="num"} 32 | } 33 | 34 | function praatPitch:loadPitch(fnam) -- constructor, short text format 35 | local o = {} 36 | setmetatable(o, self) 37 | self.__index = self 38 | 39 | local data, header = {}, {} 40 | 41 | local fi = io.open(fnam) 42 | for i = 1, #PitchHeader do 43 | local lin = fi:read("*l") 44 | local h = PitchHeader[i] 45 | 46 | if h.v then 47 | assert(lin == h.v) 48 | elseif h.t == "num" then 49 | lin = tonumber(lin) 50 | end 51 | 52 | if h.n and h.t ~= "del" then 53 | header[h.n] = lin 54 | end 55 | end 56 | 57 | header["fileType"] = "ooTextFile" 58 | header["objectClass"] = "Pitch 1" 59 | assert(header.nx) 60 | 61 | for i = 1, header.nx do 62 | local pitch = { i = i } 63 | pitch.t = (i - 1) * header.dx + header.x1 64 | 65 | local int = fi:read("*n", "*l") -- intensity 66 | local cand = fi:read("*n", "*l") -- candidates 67 | 68 | for k = 1, cand do 69 | local f = fi:read("*n", "*l") 70 | if k == 1 then 71 | pitch.f = f 72 | end 73 | fi:read("*n", "*l") 74 | end 75 | 76 | table.insert(data, pitch) 77 | end; 78 | fi:close() 79 | 80 | o.header = header 81 | o.data = data 82 | return o 83 | end 84 | 85 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 86 | if t < self.data[1].t then return 0 end 87 | if t > self.data[#self.data].t then return 0 end 88 | 89 | local ll, rr = 1, #self.data 90 | while (rr-ll) > 1 do 91 | local cc = math.floor((rr + ll) / 2) 92 | if t <= self.data[cc].t then 93 | rr = cc 94 | else 95 | ll = cc 96 | end 97 | end 98 | 99 | local pf, pt = self.data[ll].f, self.data[rr].f 100 | if pf == 0 or pt == 0 then return 0 end 101 | 102 | local pf, pt = math.log(pf), math.log(pt) 103 | local fro, til = self.data[ll].t, self.data[rr].t 104 | 105 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 106 | end 107 | 108 | ----------------- Intensity 109 | local praatIntensity = {} -- class 110 | 111 | local IntensityHeader = { 112 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 113 | {n="Object_class", v="Object class = \"Intensity 2\"", t="del"}, 114 | {t="del"}, 115 | {n="xmin", t="num"}, 116 | {n="xmax", t="num"}, 117 | {n="nx", t="num"}, 118 | {n="dx", t="num"}, 119 | {n="x1", t="num"}, 120 | {n="ymin", t="num"}, 121 | {n="ymax", t="num"}, 122 | {n="ny", t="num"}, 123 | {n="dy", t="num"}, 124 | {n="y1", t="num"} 125 | } 126 | 127 | function praatIntensity:loadIntensity(fnam) -- constructor, short text format 128 | local o = {} 129 | setmetatable(o, self) 130 | self.__index = self 131 | 132 | local data, header = {}, {} 133 | 134 | local fi = io.open(fnam) 135 | for i = 1, #IntensityHeader do 136 | local lin = fi:read("*l") 137 | local h = IntensityHeader[i] 138 | 139 | if h.v then 140 | assert(lin == h.v) 141 | elseif h.t == "num" then 142 | lin = tonumber(lin) 143 | end 144 | 145 | if h.n and h.t ~= "del" then 146 | header[h.n] = lin 147 | end 148 | end 149 | 150 | header["fileType"] = "ooTextFile" 151 | header["objectClass"] = "Intensity 2" 152 | assert(header.nx) 153 | 154 | for i = 1, header.nx do 155 | local int = { i = i } 156 | int.t = (i - 1) * header.dx + header.x1 157 | 158 | local ii = fi:read("*n", "*l") -- intensity 159 | int.db = ii 160 | table.insert(data, int) 161 | end; 162 | fi:close() 163 | 164 | o.header = header 165 | o.data = data 166 | return o 167 | end 168 | 169 | function praatIntensity:getIntensity(t) -- ret: I [dB] 170 | if t < self.data[1].t then return -100 end 171 | if t > self.data[#self.data].t then return -100 end 172 | 173 | local ll, rr = 1, #self.data 174 | while (rr-ll) > 1 do 175 | local cc = math.floor((rr + ll) / 2) 176 | if t <= self.data[cc].t then 177 | rr = cc 178 | else 179 | ll = cc 180 | end 181 | end 182 | 183 | local intf, intt = self.data[ll].db, self.data[rr].db 184 | if not intf or not intt then return -100 end 185 | 186 | local fro, til = self.data[ll].t, self.data[rr].t 187 | 188 | return intf + (intt - intf) / (til - fro) * (t - fro) 189 | end 190 | --------------- end praat 191 | 192 | local function getProjectPathName() 193 | local projectFileName = SV:getProject():getFileName() 194 | if not projectFileName then return end 195 | 196 | local projectName, projectDir 197 | projectFileName = projectFileName:gsub("\\", "/") 198 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 199 | if not projectDir or not projectName then error(T("project dir or name not found")) end 200 | 201 | return projectName, projectDir 202 | end 203 | 204 | function loadEnvelope() 205 | -- pitch file in project folder 206 | local projectName, projectDir = getProjectPathName() 207 | 208 | local pitch = praatPitch:loadPitch(projectDir..projectName.."_pitch.txt") 209 | local intens = praatIntensity:loadIntensity(projectDir..projectName.."_intensity.txt") 210 | 211 | local timeAxis = SV:getProject():getTimeAxis() 212 | local scope = SV:getMainEditor():getCurrentGroup() 213 | local group = scope:getTarget() 214 | local am = group:getParameter("vibratoEnv") 215 | 216 | local t = intens.header.xmin 217 | local tend = intens.header.xmax 218 | 219 | while t <= tend do 220 | local int = intens:getIntensity(t) or -300 221 | local env = math.sqrt(10^(int/10)*4e-10)*3 222 | 223 | local f0 = pitch:getPitch(t) 224 | if f0 < 50 then env = - env*5 end 225 | am:add(timeAxis:getBlickFromSeconds(t), env+1) 226 | t = t + 0.001 -- time step 227 | end 228 | 229 | am:simplify(timeAxis:getBlickFromSeconds(intens.header.xmin), timeAxis:getBlickFromSeconds(intens.header.xmax), 0.00005) 230 | end 231 | -------------------------------------------------------------------------------- /shift.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Shift Notes & Params" 2 | 3 | paramTypeNames = { 4 | "pitchDelta", "vibratoEnv", "loudness", "tension", "breathiness", "voicing", "gender", "toneShift" 5 | } 6 | 7 | local inputForm = { 8 | title = SV:T("Shift Notes & Params"), 9 | message = SV:T("Workaround for the \"many notes with params shifting crash\"\nshifts everything between 1st and last note selected"), 10 | buttons = "OkCancel", 11 | widgets = { 12 | { 13 | name = "cbDirection", type = "ComboBox", 14 | label = "Direction", 15 | choices = {"Forward", "Backward"}, 16 | default = 0 17 | }, 18 | { 19 | name = "cbUnit", type = "ComboBox", 20 | label = "Unit", 21 | choices = {"Measure (at 1st note)", "Quarter", "Time (sec)"}, 22 | default = 0 23 | }, 24 | { 25 | name = "slAmount", type = "Slider", 26 | label = "How much to shift", 27 | format = "%1.0f", 28 | minValue = 0, 29 | maxValue = 16, 30 | interval = 1, 31 | default = 0 32 | }, 33 | { 34 | name = "tbAmount", type = "TextBox", 35 | label = "How much to shift (can use floating point numbers)", 36 | default = "0" 37 | } 38 | } 39 | } 40 | 41 | function getClientInfo() 42 | return { 43 | name = SV:T(SCRIPT_TITLE), 44 | author = "Hataori@protonmail.com", 45 | category = "Real Voice", 46 | versionNumber = 2, 47 | minEditorVersion = 65537 48 | } 49 | end 50 | 51 | function process() 52 | local timeAxis = SV:getProject():getTimeAxis() 53 | local scope = SV:getMainEditor():getCurrentGroup() 54 | local group = scope:getTarget() 55 | -- determine start and end time 56 | local minTime_b, maxTime_b = math.huge, 0 57 | 58 | local noteCnt = group:getNumNotes() 59 | if noteCnt == 0 then -- no notes in track 60 | return 61 | else 62 | local selection = SV:getMainEditor():getSelection() 63 | local selectedNotes = selection:getSelectedNotes() 64 | if #selectedNotes == 0 then 65 | SV:showMessageBox(SV:T("Nothing selected"), SV:T("Select notes to shift (only a start and end note is enough)")) 66 | return 67 | else 68 | table.sort(selectedNotes, function(noteA, noteB) 69 | return noteA:getOnset() < noteB:getOnset() 70 | end) 71 | 72 | for _, note in ipairs(selectedNotes) do 73 | local onset_b, nend_b = note:getOnset(), note:getEnd() 74 | if onset_b < minTime_b then 75 | minTime_b = onset_b 76 | end 77 | if nend_b > maxTime_b then 78 | maxTime_b = nend_b 79 | end 80 | end 81 | end 82 | end 83 | assert(maxTime_b > minTime_b) 84 | -- list of notes to shift 85 | local notes = {} 86 | for i = 1, group:getNumNotes() do 87 | local note = group:getNote(i) 88 | local onset_b, nend_b = note:getOnset(), note:getEnd() 89 | if nend_b > minTime_b and onset_b < maxTime_b then 90 | table.insert(notes, note) 91 | end 92 | end 93 | 94 | inputForm.title = inputForm.title.." ("..#notes.." notes)" 95 | -- show dialog 96 | local dlgResult = SV:showCustomDialog(inputForm) 97 | local amount = tonumber(dlgResult.answers.tbAmount) 98 | local amountSlider = tonumber(dlgResult.answers.slAmount) 99 | if not dlgResult.status or (amount + amountSlider) == 0 then return end -- cancel pressed or no shift 100 | 101 | local direction = 1 - 2 * dlgResult.answers.cbDirection -- 1 forward, -1 backward 102 | amount = (amount + amountSlider) * direction 103 | 104 | local timeConvert, shift = false, 0 105 | local unit = dlgResult.answers.cbUnit 106 | local shift = 0 107 | if unit == 0 then 108 | local measure = timeAxis:getMeasureMarkAtBlick(minTime_b) 109 | shift = measure.numerator / measure.denominator * 4 * SV.QUARTER * amount -- in blicks 110 | elseif unit == 1 then 111 | shift = SV.QUARTER * amount 112 | else 113 | timeConvert = true 114 | end 115 | -- do the shift 116 | for _, note in ipairs(notes) do 117 | local onset_b = note:getOnset() 118 | local newOnset_b = onset_b 119 | 120 | if timeConvert then 121 | local onset = timeAxis:getSecondsFromBlick(onset_b) 122 | newOnset_b = timeAxis:getBlickFromSeconds(onset + amount) 123 | else 124 | newOnset_b = onset_b + shift 125 | end 126 | 127 | if newOnset_b < 0 then 128 | SV:showMessageBox("Error!", "You are about to move notes before zero time!\nFirst make a room by selecting all notes and shifting them forward.") 129 | return 130 | end 131 | 132 | note:setOnset(newOnset_b) 133 | end 134 | -- correction of short pauses and overlap due to rounding 135 | if timeConvert then 136 | for _, note in ipairs(notes) do 137 | local onset_b = note:getOnset() 138 | local ni = note:getIndexInParent() 139 | local nextNote = group:getNote(ni + 1) 140 | if nextNote then 141 | if math.abs(nextNote:getOnset() - note:getEnd()) <= 1 then 142 | note:setDuration(nextNote:getOnset() - note:getOnset()) 143 | end 144 | end 145 | end 146 | end 147 | 148 | for _, par in ipairs(paramTypeNames) do 149 | local am = group:getParameter(par) -- automation track 150 | -- save points to list 151 | local points = am:getPoints(minTime_b, maxTime_b) 152 | -- add boundary points 153 | local startval = am:get(minTime_b) 154 | table.insert(points, 1, {minTime_b, startval}) 155 | 156 | local endval = am:get(maxTime_b) 157 | table.insert(points, {maxTime_b, endval}) 158 | -- remove from track and add boundaries back 159 | am:remove(minTime_b, maxTime_b) 160 | am:add(minTime_b, startval) 161 | am:add(maxTime_b, endval) 162 | -- target times 163 | local newStartTime_b, newEndTime_b 164 | 165 | if timeConvert then 166 | local minTime = timeAxis:getSecondsFromBlick(minTime_b) 167 | newStartTime_b = timeAxis:getBlickFromSeconds(minTime + amount) 168 | 169 | local maxTime = timeAxis:getSecondsFromBlick(maxTime_b) 170 | newEndTime_b = timeAxis:getBlickFromSeconds(maxTime + amount) 171 | else 172 | newStartTime_b, newEndTime_b = minTime_b + shift, maxTime_b + shift 173 | end 174 | 175 | startval = am:get(newStartTime_b - 1) 176 | endval = am:get(newEndTime_b + 1) 177 | am:remove(newStartTime_b, newEndTime_b) 178 | 179 | if amount < 0 or (amount > 0 and newStartTime_b > maxTime_b) then 180 | am:add(newStartTime_b - 1, startval) 181 | end 182 | 183 | if amount > 0 or (amount < 0 and newEndTime_b < minTime_b) then 184 | am:add(newEndTime_b + 1, endval) 185 | end 186 | -- place points 187 | for _, pt in ipairs(points) do 188 | if timeConvert then 189 | local onset = timeAxis:getSecondsFromBlick(pt[1]) 190 | am:add(timeAxis:getBlickFromSeconds(onset + amount), pt[2]) 191 | else 192 | am:add(pt[1] + shift, pt[2]) 193 | end 194 | end 195 | end 196 | end 197 | 198 | function main() 199 | process() 200 | SV:finish() 201 | end 202 | -------------------------------------------------------------------------------- /loadPitch.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Load Pitch" 2 | -- Ver.1 - loads pitch from Pratt pitch object into pitch deviation automation track 3 | -- Ver.2 - minor changes 4 | -- Ver.3 - refactored pitch dev timing to consume less resources 5 | 6 | function getClientInfo() 7 | return { 8 | name = SV:T(SCRIPT_TITLE), 9 | author = "Hataori@protonmail.com", 10 | category = "Real Voice", 11 | versionNumber = 3, 12 | minEditorVersion = 0x010600 13 | } 14 | end 15 | 16 | function main() 17 | loadPraatPitch() 18 | SV:finish() 19 | end 20 | 21 | ------------ Praat pitch 22 | local praatPitch = {} -- class 23 | 24 | do 25 | 26 | local PitchHeader = { 27 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 28 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 29 | {t="del"}, 30 | {n="xmin", t="num"}, 31 | {n="xmax", t="num"}, 32 | {n="nx", t="num"}, 33 | {n="dx", t="num"}, 34 | {n="x1", t="num"}, 35 | {n="ceiling", t="num"}, 36 | {n="maxnCandidates", t="num"} 37 | } 38 | 39 | function praatPitch:loadPitch(fnam) -- constructor, short text format 40 | local o = {} 41 | setmetatable(o, self) 42 | self.__index = self 43 | 44 | local data, header = {}, {} 45 | 46 | local fi = io.open(fnam) 47 | assert(fi, "cannot open pitch file") 48 | for i = 1, #PitchHeader do 49 | local lin = fi:read("*l") 50 | local h = PitchHeader[i] 51 | 52 | if h.v then 53 | assert(lin == h.v) 54 | elseif h.t == "num" then 55 | lin = tonumber(lin) 56 | end 57 | 58 | if h.n and h.t ~= "del" then 59 | header[h.n] = lin 60 | end 61 | end 62 | 63 | header["fileType"] = "ooTextFile" 64 | header["objectClass"] = "Pitch 1" 65 | assert(header.nx, "no nx in pitch file") 66 | 67 | for i = 1, header.nx do 68 | local pitch = { i = i } 69 | pitch.t = (i - 1) * header.dx + header.x1 70 | 71 | local int = fi:read("*n", "*l") -- intensity 72 | local cand = fi:read("*n", "*l") -- candidates 73 | assert(cand, "no candidates in pitch file") 74 | 75 | for k = 1, cand do 76 | local f = fi:read("*n", "*l") 77 | if k == 1 then 78 | pitch.f = f 79 | end 80 | fi:read("*n", "*l") 81 | end 82 | 83 | table.insert(data, pitch) 84 | end; 85 | fi:close() 86 | 87 | o.header = header 88 | o.data = data 89 | return o 90 | end 91 | 92 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 93 | if t < self.data[1].t then return 0 end 94 | if t > self.data[#self.data].t then return 0 end 95 | 96 | local ll, rr = 1, #self.data 97 | while (rr-ll) > 1 do 98 | local cc = math.floor((rr + ll) / 2) 99 | if t <= self.data[cc].t then 100 | rr = cc 101 | else 102 | ll = cc 103 | end 104 | end 105 | 106 | local pf, pt = self.data[ll].f, self.data[rr].f 107 | if pf == 0 or pt == 0 then return 0 end 108 | 109 | local pf, pt = math.log(pf), math.log(pt) 110 | local fro, til = self.data[ll].t, self.data[rr].t 111 | 112 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 113 | end 114 | 115 | end -- end praat class 116 | 117 | local function getProjectPathName() 118 | local projectFileName = SV:getProject():getFileName() 119 | if not projectFileName then return end 120 | 121 | local projectName, projectDir 122 | projectFileName = projectFileName:gsub("\\", "/") 123 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 124 | return projectName, projectDir 125 | end 126 | 127 | function loadPraatPitch() 128 | -- pitch file in project folder 129 | local projectName, projectDir = getProjectPathName() 130 | if not projectDir or not projectName then 131 | SV:showMessageBox(SV:T("Error"), SV:T("Project dir or name not found, save your project first")) 132 | return 133 | end 134 | local fileName = projectDir..projectName.."_Pitch.txt" 135 | 136 | local pitch = praatPitch:loadPitch(fileName) 137 | if not pitch then 138 | SV:showMessageBox(SV:T("Error"), SV:T("Wrong pitch file format, save it as SHORT text")) 139 | return 140 | end 141 | 142 | local timeAxis = SV:getProject():getTimeAxis() 143 | local scope = SV:getMainEditor():getCurrentGroup() 144 | local group = scope:getTarget() 145 | local am = group:getParameter("pitchDelta") -- pitch automation 146 | 147 | scope:setVoice({ 148 | tF0Left = 0, 149 | tF0Right = 0, 150 | dF0Left = 0, 151 | dF0Right = 0, 152 | dF0Vbr = 0 153 | }) 154 | 155 | local notes = {} -- notes indexes 156 | 157 | local noteCnt = group:getNumNotes() 158 | if noteCnt == 0 then -- no notes 159 | return 160 | else 161 | local selection = SV:getMainEditor():getSelection() 162 | local selectedNotes = selection:getSelectedNotes() 163 | if #selectedNotes == 0 then 164 | for i = 1, noteCnt do 165 | table.insert(notes, i) 166 | end 167 | else 168 | table.sort(selectedNotes, function(noteA, noteB) 169 | return noteA:getOnset() < noteB:getOnset() 170 | end) 171 | 172 | for _, n in ipairs(selectedNotes) do 173 | table.insert(notes, n:getIndexInParent()) 174 | end 175 | end 176 | end 177 | 178 | for _, i in ipairs(notes) do 179 | local note = group:getNote(i) 180 | local npitch = note:getPitch() 181 | local ncents = 100 * (npitch - 69) -- A4 182 | 183 | local blOnset, blEnd = note:getOnset(), note:getEnd() 184 | am:remove(blOnset, blEnd) 185 | 186 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 187 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 188 | 189 | local tempo = timeAxis:getTempoMarkAt(blOnset) 190 | local compensation = tempo.bpm * 6.3417442 191 | local t_step = math.max(SV:blick2Seconds(SV:quarter2Blick(1/64), tempo.bpm), 0.01) 192 | 193 | local df, f0 194 | local o10, e10 = tons + 0.010, tend - 0.010 195 | local t = tons + 0.0005 196 | while t < tend - 0.0001 do 197 | f0 = pitch:getPitch(t) 198 | if f0 > 50 then -- voiced 199 | df = 1200 * math.log(f0/440)/math.log(2) - ncents -- delta f0 in cents 200 | am:add(timeAxis:getBlickFromSeconds(t), df) 201 | end 202 | 203 | if t <= o10 or t >= e10 then 204 | t = t + 0.001 205 | else 206 | t = t + t_step -- time step 207 | if t >= e10 then 208 | t = e10 209 | end 210 | end 211 | end 212 | 213 | if i > 1 then 214 | local pnote = group:getNote(i - 1) 215 | local pnpitch = pnote:getPitch() 216 | local pncents = 100 * (pnpitch - 69) -- A4 217 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 218 | 219 | if pblEnd == blOnset then 220 | local pts = am:getPoints(blOnset, timeAxis:getBlickFromSeconds(tons + 0.010)) 221 | local pdif = ncents - pncents 222 | 223 | for _, pt in ipairs(pts) do 224 | local b, v = pt[1], pt[2] 225 | local t = timeAxis:getSecondsFromBlick(b) - tons 226 | local cor = 1 - (1 / (1 + math.exp(-compensation * t))) 227 | am:add(b, v + pdif * cor) 228 | end 229 | end 230 | end 231 | 232 | if i < noteCnt then 233 | local pnote = group:getNote(i + 1) 234 | local pnpitch = pnote:getPitch() 235 | local pncents = 100 * (pnpitch - 69) -- A4 236 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 237 | 238 | if blEnd == pblOnset then 239 | local pts = am:getPoints(timeAxis:getBlickFromSeconds(tend - 0.010), blEnd - 1) 240 | local pdif = pncents - ncents 241 | 242 | for _, pt in ipairs(pts) do 243 | local b, v = pt[1], pt[2] 244 | local t = timeAxis:getSecondsFromBlick(b) - tend 245 | local cor = 1 / (1 + math.exp(-compensation * t)) 246 | am:add(b, v - pdif * cor) 247 | end 248 | end 249 | end 250 | 251 | am:simplify(blOnset, blEnd, 0.0001) 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /splitNote.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Split Note" 2 | -- Ver.1 - splits a note at cursor, requantizes the new notes and reloads surrounding pitch devs 3 | -- Ver.3 - minor changes 4 | -- Ver.3 - refactored pitch dev timing to consume less resources 5 | 6 | function getClientInfo() 7 | return { 8 | name = SV:T(SCRIPT_TITLE), 9 | author = "Hataori@protonmail.com", 10 | category = "Real Voice", 11 | versionNumber = 3, 12 | minEditorVersion = 0x010800 13 | } 14 | end 15 | 16 | ------------ Praat pitch 17 | local praatPitch = {} -- class 18 | 19 | do 20 | 21 | local PitchHeader = { 22 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 23 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 24 | {t="del"}, 25 | {n="xmin", t="num"}, 26 | {n="xmax", t="num"}, 27 | {n="nx", t="num"}, 28 | {n="dx", t="num"}, 29 | {n="x1", t="num"}, 30 | {n="ceiling", t="num"}, 31 | {n="maxnCandidates", t="num"} 32 | } 33 | 34 | function praatPitch:loadPitch(fnam) -- constructor, short text format 35 | local o = {} 36 | setmetatable(o, self) 37 | self.__index = self 38 | 39 | local data, header = {}, {} 40 | 41 | local fi = io.open(fnam) 42 | assert(fi, "cannot open pitch file") 43 | for i = 1, #PitchHeader do 44 | local lin = fi:read("*l") 45 | local h = PitchHeader[i] 46 | 47 | if h.v then 48 | assert(lin == h.v) 49 | elseif h.t == "num" then 50 | lin = tonumber(lin) 51 | end 52 | 53 | if h.n and h.t ~= "del" then 54 | header[h.n] = lin 55 | end 56 | end 57 | 58 | header["fileType"] = "ooTextFile" 59 | header["objectClass"] = "Pitch 1" 60 | assert(header.nx, "no nx in pitch file") 61 | 62 | for i = 1, header.nx do 63 | local pitch = { i = i } 64 | pitch.t = (i - 1) * header.dx + header.x1 65 | 66 | local int = fi:read("*n", "*l") -- intensity 67 | local cand = fi:read("*n", "*l") -- candidates 68 | assert(cand, "no candidates in pitch file") 69 | 70 | for k = 1, cand do 71 | local f = fi:read("*n", "*l") 72 | if k == 1 then 73 | pitch.f = f 74 | end 75 | fi:read("*n", "*l") 76 | end 77 | 78 | table.insert(data, pitch) 79 | end; 80 | fi:close() 81 | 82 | o.header = header 83 | o.data = data 84 | return o 85 | end 86 | 87 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 88 | if t < self.data[1].t then return 0 end 89 | if t > self.data[#self.data].t then return 0 end 90 | 91 | local ll, rr = 1, #self.data 92 | while (rr-ll) > 1 do 93 | local cc = math.floor((rr + ll) / 2) 94 | if t <= self.data[cc].t then 95 | rr = cc 96 | else 97 | ll = cc 98 | end 99 | end 100 | 101 | local pf, pt = self.data[ll].f, self.data[rr].f 102 | if pf == 0 or pt == 0 then return 0 end 103 | 104 | local pf, pt = math.log(pf), math.log(pt) 105 | local fro, til = self.data[ll].t, self.data[rr].t 106 | 107 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 108 | end 109 | 110 | end -- end class 111 | 112 | --------- qunatization 113 | 114 | local function HzToHalftone(hz) 115 | if hz<=0 then return end 116 | return 12 * math.log(hz / 440) / math.log(2) 117 | end 118 | 119 | local function medianNotePitch(noteStart, noteEnd, pitch) -- times in secs 120 | local med, cnt, unvoc = {}, 0, 0 121 | local t = noteStart 122 | while t <= noteEnd do 123 | local f0 = pitch:getPitch(t) 124 | if f0 > 0 then 125 | local qn = math.floor(HzToHalftone(f0) + 0.5) 126 | table.insert(med, qn) 127 | else 128 | unvoc = unvoc + 1 129 | end 130 | 131 | t = t + 0.001 132 | cnt = cnt + 1 133 | end 134 | 135 | if #med > 2 and (#med / cnt) > 0.5 then -- more than 50% voiced length 136 | table.sort(med) 137 | med = med[math.floor(#med / 2) + 1] 138 | return med + 69 139 | end 140 | end 141 | 142 | --------- project 143 | 144 | local function getProjectPathName() 145 | local projectFileName = SV:getProject():getFileName() 146 | if not projectFileName then return end 147 | 148 | local projectName, projectDir 149 | projectFileName = projectFileName:gsub("\\", "/") 150 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 151 | return projectName, projectDir 152 | end 153 | 154 | ---------- main 155 | 156 | function process() 157 | local sver = SV:getHostInfo().hostVersionNumber -- SynthV version 158 | -- pitch file in project folder 159 | local projectName, projectDir = getProjectPathName() 160 | if not projectDir or not projectName then 161 | SV:showMessageBox(SV:T("Error"), SV:T("Project dir or name not found, save your project first")) 162 | return 163 | end 164 | local fileName = projectDir..projectName.."_Pitch.txt" 165 | 166 | local fi = io.open(fileName) 167 | if not fi then 168 | SV:showMessageBox(SV:T("Error"), SV:T("Cannot open pitch file").." '"..fileName.."'") 169 | return 170 | else 171 | fi:close() 172 | end 173 | 174 | local pitch = praatPitch:loadPitch(fileName) -- pitch instance 175 | if not pitch then 176 | SV:showMessageBox(SV:T("Error"), SV:T("Wrong pitch file format, save it as SHORT text")) 177 | return 178 | end 179 | 180 | local timeAxis = SV:getProject():getTimeAxis() 181 | local scope = SV:getMainEditor():getCurrentGroup() 182 | local group = scope:getTarget() 183 | local playback = SV:getPlayback() 184 | local am = group:getParameter("pitchDelta") -- pitch automation 185 | 186 | local ph = playback:getPlayhead() -- in secs 187 | local phb = timeAxis:getBlickFromSeconds(ph) 188 | -- find note index 189 | local ni 190 | for i = 1, group:getNumNotes() do 191 | local note = group:getNote(i) 192 | if phb >= note:getOnset() and phb < note:getEnd() then 193 | ni = i 194 | break 195 | end 196 | end 197 | -- note not under playhead cursor 198 | if not ni then return end 199 | 200 | local noteL = group:getNote(ni) 201 | local nLpitch = medianNotePitch(timeAxis:getSecondsFromBlick(noteL:getOnset()), ph, pitch) 202 | local nRpitch = medianNotePitch(ph, timeAxis:getSecondsFromBlick(noteL:getEnd()), pitch) 203 | 204 | local notes = {} -- notes indexes for pitch reload 205 | local noteCnt = group:getNumNotes() 206 | 207 | if nLpitch and not nRpitch then 208 | noteL:setPitch(nLpitch) 209 | noteL:setDuration(phb - noteL:getOnset()) 210 | 211 | local ifr, ito = ni - 1, ni + 1 212 | if ifr < 1 then ifr = 1 end 213 | if ito > noteCnt then ito = noteCnt end 214 | 215 | for i = ifr, ito do 216 | table.insert(notes, i) 217 | end 218 | return 219 | elseif not nLpitch and nRpitch then 220 | noteL:setPitch(nRpitch) 221 | noteL:setTimeRange(phb, noteL:getEnd() - phb) 222 | 223 | local ifr, ito = ni - 1, ni + 1 224 | if ifr < 1 then ifr = 1 end 225 | if ito > noteCnt then ito = noteCnt end 226 | 227 | for i = ifr, ito do 228 | table.insert(notes, i) 229 | end 230 | return 231 | elseif nLpitch and nRpitch then 232 | local noteR = SV:create("Note") 233 | noteR:setTimeRange(phb, noteL:getEnd() - phb) 234 | if sver >= 0x010900 then 235 | noteR:setPitchAutoMode(0) -- manual pitch mode 236 | end 237 | noteR:setPitch(nRpitch) 238 | noteR:setLyrics("-") 239 | group:addNote(noteR) 240 | 241 | noteL:setPitch(nLpitch) 242 | noteL:setDuration(phb - noteL:getOnset()) 243 | 244 | local ifr, ito = ni - 1, ni + 2 245 | if ifr < 1 then ifr = 1 end 246 | if ito > noteCnt then ito = noteCnt end 247 | 248 | for i = ifr, ito do 249 | table.insert(notes, i) 250 | end 251 | end 252 | -- reload pitch 253 | for _, i in ipairs(notes) do 254 | local note = group:getNote(i) 255 | local npitch = note:getPitch() 256 | local ncents = 100 * (npitch - 69) -- A4 257 | 258 | local blOnset, blEnd = note:getOnset(), note:getEnd() 259 | am:remove(blOnset, blEnd) 260 | 261 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 262 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 263 | 264 | local tempo = timeAxis:getTempoMarkAt(blOnset) 265 | local compensation = tempo.bpm * 6.3417442 266 | local t_step = math.max(SV:blick2Seconds(SV:quarter2Blick(1/64), tempo.bpm), 0.01) 267 | 268 | local df, f0 269 | local o10, e10 = tons + 0.010, tend - 0.010 270 | local t = tons + 0.0005 271 | while t < tend - 0.0001 do 272 | f0 = pitch:getPitch(t) 273 | if f0 > 50 then -- voiced 274 | df = 1200 * math.log(f0/440)/math.log(2) - ncents -- delta f0 in cents 275 | am:add(timeAxis:getBlickFromSeconds(t), df) 276 | end 277 | 278 | if t <= o10 or t >= e10 then 279 | t = t + 0.001 280 | else 281 | t = t + t_step -- time step 282 | if t >= e10 then 283 | t = e10 284 | end 285 | end 286 | end 287 | 288 | if i > 1 then 289 | local pnote = group:getNote(i - 1) 290 | local pnpitch = pnote:getPitch() 291 | local pncents = 100 * (pnpitch - 69) -- A4 292 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 293 | 294 | if pblEnd == blOnset then 295 | local pts = am:getPoints(blOnset, timeAxis:getBlickFromSeconds(tons + 0.010)) 296 | local pdif = ncents - pncents 297 | 298 | for _, pt in ipairs(pts) do 299 | local b, v = pt[1], pt[2] 300 | local t = timeAxis:getSecondsFromBlick(b) - tons 301 | local cor = 1 - (1 / (1 + math.exp(-compensation * t))) 302 | am:add(b, v + pdif * cor) 303 | end 304 | end 305 | end 306 | 307 | if i < noteCnt then 308 | local pnote = group:getNote(i + 1) 309 | local pnpitch = pnote:getPitch() 310 | local pncents = 100 * (pnpitch - 69) -- A4 311 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 312 | 313 | if blEnd == pblOnset then 314 | local pts = am:getPoints(timeAxis:getBlickFromSeconds(tend - 0.010), blEnd - 1) 315 | local pdif = pncents - ncents 316 | 317 | for _, pt in ipairs(pts) do 318 | local b, v = pt[1], pt[2] 319 | local t = timeAxis:getSecondsFromBlick(b) - tend 320 | local cor = 1 / (1 + math.exp(-compensation * t)) 321 | am:add(b, v - pdif * cor) 322 | end 323 | end 324 | end 325 | 326 | am:simplify(blOnset, blEnd, 0.0001) 327 | end 328 | end 329 | 330 | function main() 331 | process() 332 | SV:finish() 333 | end 334 | -------------------------------------------------------------------------------- /growl.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Growl" 2 | 3 | local inputForm = { 4 | title = SV:T("Growl"), 5 | message = SV:T(""), 6 | buttons = "OkCancel", 7 | widgets = { 8 | { 9 | name = "slFreq", type = "Slider", 10 | label = "Base frequency of the modualtor (Hz)", 11 | format = "%3.0f", 12 | minValue = 10, 13 | maxValue = 200, 14 | interval = 1, 15 | default = 50 16 | }, 17 | { 18 | name = "chkVibr", type = "CheckBox", 19 | text = SV:T("Vibrato mode - divide base frequency by 10"), 20 | default = false 21 | }, 22 | { 23 | name = "slPitch", type = "Slider", 24 | label = "Modulation depth for Pitch (smt)", 25 | format = "%5.2f", 26 | minValue = 0, 27 | maxValue = 2.0, 28 | interval = 0.01, 29 | default = 0 30 | }, 31 | { 32 | name = "slLoud", type = "Slider", 33 | label = "Modulation depth for Loudness (dB)", 34 | format = "%3.0f", 35 | minValue = 0, 36 | maxValue = 12, 37 | interval = 1, 38 | default = 0 39 | }, 40 | { 41 | name = "slTens", type = "Slider", 42 | label = "Modulation depth for Tension", 43 | format = "%5.2f", 44 | minValue = 0, 45 | maxValue = 1.0, 46 | interval = 0.01, 47 | default = 0.0 48 | }, 49 | { 50 | name = "slGend", type = "Slider", 51 | label = "Modulation depth for Gender", 52 | format = "%5.2f", 53 | minValue = 0, 54 | maxValue = 1.0, 55 | interval = 0.01, 56 | default = 0.0 57 | }, 58 | { 59 | name = "slRand", type = "Slider", 60 | label = "Random phase modulation", 61 | format = "%5.2f", 62 | minValue = 0, 63 | maxValue = 1.0, 64 | interval = 0.01, 65 | default = 0.0 66 | }, 67 | { 68 | name = "chkEnv", type = "CheckBox", 69 | text = SV:T("Use \"Vibrato Envelope\" for additional depth modulation"), 70 | default = false 71 | }, 72 | } 73 | } 74 | 75 | function getClientInfo() 76 | return { 77 | name = SV:T(SCRIPT_TITLE), 78 | author = "Hataori@protonmail.com", 79 | category = "Real Voice", 80 | versionNumber = 2, 81 | minEditorVersion = 65537 82 | } 83 | end 84 | 85 | function main() 86 | process() 87 | SV:finish() 88 | end 89 | 90 | function process() 91 | local projectFileName = SV:getProject():getFileName() 92 | local configFileName = nil 93 | if projectFileName ~= "" then 94 | -- projectFileName may be nil if file is not saved or running in VST 95 | configFileName = projectFileName..".RVgrowl.cfg" 96 | end 97 | -- read config 98 | if configFileName then 99 | local fi = io.open(configFileName) 100 | if fi then 101 | local loadSuccessful = true 102 | local txt = fi:read("*a") 103 | fi:close() 104 | 105 | local conf = load("return"..txt)() 106 | if (conf == false) then 107 | SV:showMessageBox(SV:T("Warning"), SV:T("Cannot load config file")) 108 | loadSuccessful = false 109 | elseif type(conf) ~= "table" then 110 | SV:showMessageBox(SV:T("Warning"), SV:T("Config format error")) 111 | loadSuccessful = false 112 | end 113 | -- dialog defaults from config 114 | if loadSuccessful then 115 | local wg = inputForm.widgets 116 | wg[1].default = conf.baseFrequency or 50 117 | if conf.vibratoMode and conf.vibratoMode > 0 then 118 | wg[2].default = true 119 | else 120 | wg[2].default = false 121 | end 122 | wg[3].default = conf.pitchDepth or 0 123 | wg[4].default = conf.loudnessDepth or 0 124 | wg[5].default = conf.tensionDepth or 0.0 125 | wg[6].default = conf.genderDepth or 0.0 126 | wg[7].default = conf.randomPhase or 0.0 127 | if conf.useEnvelope and conf.useEnvelope > 0 then 128 | wg[8].default = true 129 | else 130 | wg[8].default = false 131 | end 132 | end 133 | end 134 | end 135 | -- input dialog 136 | local dlgResult = SV:showCustomDialog(inputForm) 137 | if not dlgResult.status then return end -- cancel pressed 138 | 139 | local slFreq = dlgResult.answers.slFreq 140 | local chkVibr = dlgResult.answers.chkVibr 141 | local slPitch = dlgResult.answers.slPitch 142 | local slLoud = dlgResult.answers.slLoud 143 | local slTens = dlgResult.answers.slTens 144 | local slGend = dlgResult.answers.slGend 145 | local slRand = dlgResult.answers.slRand 146 | local chkEnv = dlgResult.answers.chkEnv 147 | -- save configuration 148 | if configFileName then 149 | local fo, errMessage = io.open(configFileName, "w") 150 | if fo then 151 | fo:write("{\n") 152 | fo:write("baseFrequency="..slFreq..",\n") 153 | if chkVibr then 154 | fo:write("vibratoMode=1,\n") 155 | else 156 | fo:write("vibratoMode=0,\n") 157 | end 158 | fo:write("pitchDepth="..slPitch..",\n") 159 | fo:write("loudnessDepth="..slLoud..",\n") 160 | fo:write("tensionDepth="..slTens..",\n") 161 | fo:write("genderDepth="..slGend..",\n") 162 | fo:write("randomPhase="..slRand..",\n") 163 | if chkEnv then 164 | fo:write("useEnvelope=1,\n") 165 | else 166 | fo:write("useEnvelope=0,\n") 167 | end 168 | fo:write("}\n") 169 | fo:close() 170 | else 171 | SV:showMessageBox(SV:T("Warning"), SV:T("Unable to save configuration, check if there are any non-English characters in the path").."\n"..errMessage) 172 | end 173 | end 174 | -- vibrato mode 175 | if chkVibr then slFreq = slFreq / 10 end 176 | -- SV automations 177 | local project = SV:getProject() 178 | local timeAxis = project:getTimeAxis() 179 | local scope = SV:getMainEditor():getCurrentGroup() 180 | local group = scope:getTarget() 181 | local amenv = group:getParameter("vibratoEnv") -- modulation envelope 182 | local ampt = group:getParameter("pitchDelta") 183 | local amld = group:getParameter("loudness") 184 | local amts = group:getParameter("tension") 185 | local amgen = group:getParameter("gender") 186 | -- find associated track by name 187 | local ctName = SV:getMainEditor():getCurrentTrack():getName() 188 | local amenv2 189 | for i = 1, project:getNumTracks() do 190 | local tr = project:getTrack(i) 191 | if tr:getName() == ctName.."-growl" then 192 | amenv2 = tr:getGroupReference(1):getTarget():getParameter("vibratoEnv") 193 | break 194 | end 195 | end 196 | -- selected notes list 197 | local notes = {} -- notes indexes 198 | 199 | local noteCnt = group:getNumNotes() 200 | if noteCnt == 0 then -- no notes 201 | return 202 | else 203 | local selection = SV:getMainEditor():getSelection() 204 | local selectedNotes = selection:getSelectedNotes() 205 | if #selectedNotes == 0 then 206 | SV:showMessageBox("Error", SV:T("Nothing selected")) 207 | return 208 | else 209 | table.sort(selectedNotes, function(noteA, noteB) 210 | return noteA:getOnset() < noteB:getOnset() 211 | end) 212 | 213 | for _, n in ipairs(selectedNotes) do 214 | table.insert(notes, n:getIndexInParent()) 215 | end 216 | end 217 | end 218 | 219 | local firststart 220 | for _, i in ipairs(notes) do 221 | local note = group:getNote(i) 222 | 223 | local blOnset, blEnd = note:getOnset(), note:getEnd() 224 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 225 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 226 | if not firststart then firststart = tons end 227 | 228 | local lastpointPitch = ampt:get(blEnd + 1) 229 | local lastpointLoud = amld:get(blEnd + 1) 230 | local lastpointTens = amts:get(blEnd + 1) 231 | local lastpointGend = amgen:get(blEnd + 1) 232 | 233 | local result = {} 234 | 235 | local t = tons 236 | while t < tend do 237 | local tbl = timeAxis:getBlickFromSeconds(t) 238 | local t0 = t - firststart 239 | -- frequency envelope 240 | local fenv = 1.0 241 | if amenv2 then 242 | fenv = amenv2:get(tbl) 243 | end 244 | 245 | local sn = math.sin(2 * math.pi * (fenv * slFreq + 1) * t0 + 2 * math.pi * slRand * math.random()) -- sin wave generator 246 | -- modulation envelope (0 - 1) from "Vibrato Envelope" automation 247 | local env = 1.0 248 | if chkEnv then 249 | env = amenv:get(tbl) - 1.0 250 | if env < 0 then env = 0 end 251 | end 252 | 253 | local res = { tbl = tbl } 254 | -- pitch modulation 255 | res.df = env * sn * 100 * slPitch + ampt:get(tbl) 256 | -- loudness modulation 257 | res.ld = env * sn * slLoud + amld:get(tbl) 258 | -- tension modulation 259 | res.ten = env * sn * slTens + amts:get(tbl) 260 | -- gender modulation 261 | res.gen = env * sn * slGend + amgen:get(tbl) 262 | 263 | table.insert(result, res) 264 | 265 | t = t + 0.001 -- time step 266 | end 267 | -- remove all previous points 268 | if slPitch > 0 then 269 | ampt:remove(blOnset, blEnd) 270 | end 271 | if slLoud > 0 then 272 | amld:remove(blOnset, blEnd) 273 | end 274 | if slTens > 0 then 275 | amts:remove(blOnset, blEnd) 276 | end 277 | if slGend > 0 then 278 | amgen:remove(blOnset, blEnd) 279 | end 280 | -- add new points 281 | for _, res in ipairs(result) do 282 | if slPitch > 0 then 283 | ampt:add(res.tbl, res.df) 284 | end 285 | if slLoud > 0 then 286 | amld:add(res.tbl, res.ld) 287 | end 288 | if slTens > 0 then 289 | amts:add(res.tbl, res.ten) 290 | end 291 | if slGend > 0 then 292 | amgen:add(res.tbl, res.gen) 293 | end 294 | end 295 | -- simplify 296 | if slPitch > 0 then 297 | ampt:simplify(blOnset, blEnd, 0.00001) 298 | end 299 | if slLoud > 0 then 300 | amld:simplify(blOnset, blEnd, 0.00001) 301 | end 302 | if slTens > 0 then 303 | amts:simplify(blOnset, blEnd, 0.00001) 304 | end 305 | if slGend > 0 then 306 | amgen:simplify(blOnset, blEnd, 0.00001) 307 | end 308 | -- restore curve after note 309 | ampt:add(blEnd + 1, lastpointPitch) 310 | amld:add(blEnd + 1, lastpointLoud) 311 | amts:add(blEnd + 1, lastpointTens) 312 | amgen:add(blEnd + 1, lastpointGend) 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /quantizePitch.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Quantize Pitch" 2 | 3 | function getClientInfo() 4 | return { 5 | name = SV:T(SCRIPT_TITLE), 6 | author = "Hataori@protonmail.com", 7 | category = "Real Voice", 8 | versionNumber = 2, 9 | minEditorVersion = 65537 10 | } 11 | end 12 | 13 | local NOTES = {'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B'} 14 | 15 | local SCALES = { 16 | ['chroma'] = 'C-C#-D-D#-E-F-F#-G-G#-A-Bb-B-', 17 | ['C/a'] = 'C-D-E-F-G-A-B-', 18 | ['C#/Db/bb'] = 'C#-D#-F-F#-G#-Bb-C-', 19 | ['D/b'] = 'D-E-F#-G-A-B-C#-', 20 | ['Eb/c'] = 'D#-F-G-G#-Bb-C-D-', 21 | ['E/c#'] = 'E-F#-G#-A-B-C#-D#-', 22 | ['F/d'] = 'F-G-A-Bb-C-D-E-', 23 | ['F#/Gb/d#/eb'] = 'F#-G#-Bb-B-C#-D#-F-', 24 | ['G/e'] = 'G-A-B-C-D-E-F#-', 25 | ['Ab/f'] = 'G#-Bb-C-C#-D#-F-G-', 26 | ['A/f#'] = 'A-B-C#-D-E-F#-G#-', 27 | ['Bb/g'] = 'Bb-C-D-D#-F-G-A-', 28 | ['B/Cb/g#'] = 'B-C#-D#-E-F#-G#-Bb-' 29 | } 30 | 31 | local inputForm = { 32 | title = SV:T("Quantization parameters"), 33 | message = SV:T("Set tempo before running this script."), 34 | buttons = "OkCancel", 35 | widgets = { 36 | { 37 | name = "qDiv", type = "ComboBox", 38 | label = SV:T("Time resolution"), 39 | choices = {"Quarter", "1/2 Quarter", "1/4 Quarter", "1/8 Quarter", "1/16 Quarter"}, 40 | default = 1 41 | }, 42 | { 43 | name = "scale", type = "ComboBox", 44 | label = SV:T("Scale (Maj/Min)"), 45 | choices = {"chroma", "C/a", "C#/Db/bb", "D/b", "Eb/c", "E/c#", "F/d", "F#/Gb/d#/eb", "G/e", "Ab/f", "A/f#", "Bb/g", "B/Cb/g#"}, 46 | default = 0 47 | }, 48 | { 49 | name = "defLyr", type = "TextBox", 50 | label = SV:T("Lyrics for all notes"), 51 | default = "u" 52 | }, 53 | { 54 | name = "loadPitchCheck", type = "CheckBox", 55 | text = SV:T("Load pitch automation"), 56 | default = false 57 | } 58 | } 59 | } 60 | 61 | ------------ Praat pitch 62 | local praatPitch = {} -- class 63 | 64 | do 65 | 66 | local PitchHeader = { 67 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 68 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 69 | {t="del"}, 70 | {n="xmin", t="num"}, 71 | {n="xmax", t="num"}, 72 | {n="nx", t="num"}, 73 | {n="dx", t="num"}, 74 | {n="x1", t="num"}, 75 | {n="ceiling", t="num"}, 76 | {n="maxnCandidates", t="num"} 77 | } 78 | 79 | function praatPitch:loadPitch(fnam) -- constructor, short text format 80 | local o = {} 81 | setmetatable(o, self) 82 | self.__index = self 83 | 84 | local data, header = {}, {} 85 | 86 | local fi = io.open(fnam) 87 | for i = 1, #PitchHeader do 88 | local lin = fi:read("*l") 89 | local h = PitchHeader[i] 90 | 91 | if h.v then 92 | assert(lin == h.v) 93 | elseif h.t == "num" then 94 | lin = tonumber(lin) 95 | end 96 | 97 | if h.n and h.t ~= "del" then 98 | header[h.n] = lin 99 | end 100 | end 101 | 102 | header["fileType"] = "ooTextFile" 103 | header["objectClass"] = "Pitch 1" 104 | assert(header.nx) 105 | 106 | for i = 1, header.nx do 107 | local pitch = { i = i } 108 | pitch.t = (i - 1) * header.dx + header.x1 109 | 110 | local int = fi:read("*n", "*l") -- intensity 111 | local cand = fi:read("*n", "*l") -- candidates 112 | 113 | for k = 1, cand do 114 | local f = fi:read("*n", "*l") 115 | if k == 1 then 116 | pitch.f = f 117 | end 118 | fi:read("*n", "*l") 119 | end 120 | 121 | table.insert(data, pitch) 122 | end; 123 | fi:close() 124 | 125 | o.header = header 126 | o.data = data 127 | return o 128 | end 129 | 130 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 131 | if t < self.data[1].t then return 0 end 132 | if t > self.data[#self.data].t then return 0 end 133 | 134 | local ll, rr = 1, #self.data 135 | while (rr-ll) > 1 do 136 | local cc = math.floor((rr + ll) / 2) 137 | if t <= self.data[cc].t then 138 | rr = cc 139 | else 140 | ll = cc 141 | end 142 | end 143 | 144 | local pf, pt = self.data[ll].f, self.data[rr].f 145 | if pf == 0 or pt == 0 then return 0 end 146 | 147 | local pf, pt = math.log(pf), math.log(pt) 148 | local fro, til = self.data[ll].t, self.data[rr].t 149 | 150 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 151 | end 152 | 153 | end -- end class 154 | 155 | --------- qunatization 156 | 157 | function HzToHalftone(hz) 158 | if hz<=0 then return end 159 | return 12 * math.log(hz / 440) / math.log(2) 160 | end 161 | 162 | -- halftone number from HzToHalftone, 0 = 440 Hz, scale string from SCALES 163 | function isInScale(ht, scale) 164 | local cbase = math.fmod(ht + 57, 12) -- every C = 0 165 | local nam = NOTES[cbase + 1] 166 | return string.find(scale, nam.."-", 1, true) 167 | end 168 | 169 | -- quantize to scale 170 | function quantizeNote(hz, scale) 171 | local ht = HzToHalftone(hz) 172 | if not ht then return end 173 | 174 | local qht = math.floor(ht + 0.5) 175 | if isInScale(qht, scale) then return qht end 176 | 177 | local lht, hht = qht - 1, qht + 1 178 | while not isInScale(lht, scale) do 179 | lht = lht - 1 180 | end 181 | 182 | while not isInScale(hht, scale) do 183 | hht = hht + 1 184 | end 185 | 186 | if math.abs(ht - lht) < math.abs(ht - hht) then 187 | return lht 188 | else 189 | return hht 190 | end 191 | end 192 | 193 | local function getProjectPathName() 194 | local projectFileName = SV:getProject():getFileName() 195 | if not projectFileName then return end 196 | 197 | local projectName, projectDir 198 | projectFileName = projectFileName:gsub("\\", "/") 199 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 200 | if not projectDir or not projectName then error(SV:T("project dir or name not found")) end 201 | 202 | return projectName, projectDir 203 | end 204 | 205 | function process() 206 | -- pitch file in project folder 207 | local projectName, projectDir = getProjectPathName() 208 | local fileName = projectDir..projectName.."_pitch.txt" 209 | 210 | local fi = io.open(fileName) 211 | if not fi then 212 | SV:showMessageBox(SV:T("Error"), SV:T("Cannot open pitch file").." '"..fileName.."'") 213 | return 214 | else 215 | fi:close() 216 | end 217 | 218 | local dlgResult = SV:showCustomDialog(inputForm) 219 | if not dlgResult.status then return end -- cancel pressed 220 | 221 | local quarterDivider = 2^dlgResult.answers.qDiv 222 | local qInterval = SV.QUARTER / quarterDivider 223 | 224 | local scaleName = inputForm.widgets[2].choices[dlgResult.answers.scale + 1] 225 | local qScale = SCALES[scaleName] 226 | assert(qScale) 227 | 228 | local pitch = praatPitch:loadPitch(fileName) -- pitch instance 229 | if not pitch then 230 | SV:showMessageBox(SV:T("Error"), SV:T("wrong file format")) 231 | return 232 | end 233 | 234 | local timeAxis = SV:getProject():getTimeAxis() 235 | local scope = SV:getMainEditor():getCurrentGroup() 236 | local group = scope:getTarget() 237 | 238 | local tstart = pitch.header.xmin 239 | local tend = pitch.header.xmax 240 | 241 | local ints = {} 242 | local tms = timeAxis:getAllTempoMarks() 243 | table.insert(tms, {position = timeAxis:getBlickFromSeconds(tend), positionSeconds = tend}) 244 | for tmi = 1, #tms - 1 do 245 | local tm = tms[tmi] 246 | 247 | local intNum = SV:blickRoundDiv(tms[tmi + 1].position - tm.position, qInterval) 248 | 249 | local b = tm.position 250 | for i = 1, intNum do 251 | local tst, ten = timeAxis:getSecondsFromBlick(b), timeAxis:getSecondsFromBlick(b + qInterval) 252 | 253 | local med, cnt, unvoc = {}, 0, 0 254 | local t = tst 255 | while t <= ten do 256 | local f0 = pitch:getPitch(t) 257 | if f0 > 50 then 258 | local qn = quantizeNote(f0, qScale) 259 | table.insert(med, qn) 260 | else 261 | unvoc = unvoc + 1 262 | end 263 | 264 | t = t + 0.001 265 | cnt = cnt + 1 266 | end 267 | 268 | if #med > 2 and (#med / cnt) > 0.5 then -- more than 50% voiced length 269 | table.sort(med) 270 | med = med[math.floor(#med / 2) + 1] 271 | 272 | table.insert(ints, {st = b, en = b + qInterval, pitch = med + 69}) 273 | else 274 | table.insert(ints, {st = b, en = b + qInterval}) 275 | end 276 | 277 | b = b + qInterval 278 | end 279 | end 280 | 281 | local notes = {} 282 | 283 | local i, j = 1, 1 284 | while i <= #ints do 285 | local p0 = ints[i].pitch 286 | j = i + 1 287 | while j <= #ints and ints[j].pitch and ints[j].pitch == p0 do 288 | j = j + 1 289 | end 290 | 291 | table.insert(notes, {st = ints[i].st, en = ints[j - 1].en, pitch = p0}) 292 | 293 | while j <= #ints and not ints[j].pitch do 294 | j = j + 1 295 | end 296 | 297 | i = j 298 | end 299 | -- remove old notes 300 | local ncnt = group:getNumNotes() 301 | if ncnt > 0 then 302 | for i = ncnt, 1, -1 do 303 | group:removeNote(i) 304 | end 305 | end 306 | -- create notes 307 | for i, nt in ipairs(notes) do 308 | local note = SV:create("Note") 309 | note:setTimeRange(nt.st, nt.en - nt.st) 310 | note:setPitch(nt.pitch) 311 | note:setLyrics(dlgResult.answers.defLyr) 312 | group:addNote(note) 313 | end 314 | -- load pitch automation 315 | if dlgResult.answers.loadPitchCheck then 316 | local am = group:getParameter("pitchDelta") -- pitch automation 317 | am:removeAll() 318 | 319 | scope:setVoice({ 320 | tF0Left = 0, 321 | tF0Right = 0, 322 | dF0Left = 0, 323 | dF0Right = 0, 324 | dF0Vbr = 0 325 | }) 326 | 327 | local minblicks, maxblicks = math.huge, 0 328 | for i = 1, group:getNumNotes() do 329 | local note = group:getNote(i) 330 | local npitch = note:getPitch() 331 | local ncents = 100 * (npitch - 69) -- A4 332 | 333 | local blOnset, blEnd = note:getOnset(), note:getEnd() 334 | am:remove(blOnset, blEnd) 335 | 336 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 337 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 338 | 339 | local df, f0 340 | local t = tons + 0.0005 341 | while t < tend - 0.0001 do 342 | f0 = pitch:getPitch(t) 343 | if f0 > 50 then -- voiced 344 | df = 1200 * math.log(f0/440)/math.log(2) - ncents -- delta f0 in cents 345 | am:add(timeAxis:getBlickFromSeconds(t), df) 346 | end 347 | t = t + 0.001 -- time step 348 | end 349 | 350 | local tempo = timeAxis:getTempoMarkAt(blOnset) 351 | local compensation = tempo.bpm * 6.3417442 352 | 353 | if i > 1 then 354 | local pnote = group:getNote(i - 1) 355 | local pnpitch = pnote:getPitch() 356 | local pncents = 100 * (pnpitch - 69) -- A4 357 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 358 | local ptons = timeAxis:getSecondsFromBlick(pblOnset) -- start time 359 | local ptend = timeAxis:getSecondsFromBlick(pblEnd) -- end time 360 | 361 | if pblEnd == blOnset then 362 | local pts = am:getPoints(blOnset, timeAxis:getBlickFromSeconds(tons + 0.010)) 363 | local pdif = ncents - pncents 364 | 365 | for _, pt in ipairs(pts) do 366 | local b, v = pt[1], pt[2] 367 | local t = timeAxis:getSecondsFromBlick(b) - tons 368 | local cor = 1 - (1 / (1 + math.exp(-compensation * t))) 369 | am:add(b, v + pdif * cor) 370 | end 371 | end 372 | end 373 | 374 | if i < group:getNumNotes() then 375 | local pnote = group:getNote(i + 1) 376 | local pnpitch = pnote:getPitch() 377 | local pncents = 100 * (pnpitch - 69) -- A4 378 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 379 | local ptons = timeAxis:getSecondsFromBlick(pblOnset) -- start time 380 | local ptend = timeAxis:getSecondsFromBlick(pblEnd) -- end time 381 | 382 | if blEnd == pblOnset then 383 | local pts = am:getPoints(timeAxis:getBlickFromSeconds(tend - 0.010), blEnd - 1) 384 | local pdif = pncents - ncents 385 | 386 | for _, pt in ipairs(pts) do 387 | local b, v = pt[1], pt[2] 388 | local t = timeAxis:getSecondsFromBlick(b) - tend 389 | local cor = 1 / (1 + math.exp(-compensation * t)) 390 | am:add(b, v - pdif * cor) 391 | end 392 | end 393 | end 394 | 395 | am:simplify(blOnset, blEnd, 0.0001) 396 | end 397 | 398 | end 399 | end 400 | 401 | function main() 402 | process() 403 | SV:finish() 404 | end 405 | -------------------------------------------------------------------------------- /filterPitch.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Filter Pitch" 2 | 3 | function getClientInfo() 4 | return { 5 | name = SV:T(SCRIPT_TITLE), 6 | author = "hataori@protonmail.com", 7 | category = "Real Voice", 8 | versionNumber = 2, 9 | minEditorVersion = 65537 10 | } 11 | end 12 | 13 | ------------ Praat pitch 14 | local praatPitch = {} -- class 15 | 16 | do 17 | 18 | local PitchHeader = { 19 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 20 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 21 | {t="del"}, 22 | {n="xmin", t="num"}, 23 | {n="xmax", t="num"}, 24 | {n="nx", t="num"}, 25 | {n="dx", t="num"}, 26 | {n="x1", t="num"}, 27 | {n="ceiling", t="num"}, 28 | {n="maxnCandidates", t="num"} 29 | } 30 | 31 | function praatPitch:loadPitch(fnam) -- constructor, short text format 32 | local o = {} 33 | setmetatable(o, self) 34 | self.__index = self 35 | 36 | local data, header = {}, {} 37 | 38 | local fi = io.open(fnam) 39 | for i = 1, #PitchHeader do 40 | local lin = fi:read("*l") 41 | local h = PitchHeader[i] 42 | 43 | if h.v then 44 | assert(lin == h.v) 45 | elseif h.t == "num" then 46 | lin = tonumber(lin) 47 | end 48 | 49 | if h.n and h.t ~= "del" then 50 | header[h.n] = lin 51 | end 52 | end 53 | 54 | header["fileType"] = "ooTextFile" 55 | header["objectClass"] = "Pitch 1" 56 | assert(header.nx) 57 | 58 | for i = 1, header.nx do 59 | local pitch = { i = i } 60 | pitch.t = (i - 1) * header.dx + header.x1 61 | 62 | local int = fi:read("*n", "*l") -- intensity 63 | local cand = fi:read("*n", "*l") -- candidates 64 | 65 | for k = 1, cand do 66 | local f = fi:read("*n", "*l") 67 | if k == 1 then 68 | pitch.f = f 69 | end 70 | fi:read("*n", "*l") 71 | end 72 | 73 | table.insert(data, pitch) 74 | end; 75 | fi:close() 76 | 77 | o.header = header 78 | o.data = data 79 | return o 80 | end 81 | 82 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 83 | if t < self.data[1].t then return 0 end 84 | if t > self.data[#self.data].t then return 0 end 85 | 86 | local ll, rr = 1, #self.data 87 | while (rr-ll) > 1 do 88 | local cc = math.floor((rr + ll) / 2) 89 | if t <= self.data[cc].t then 90 | rr = cc 91 | else 92 | ll = cc 93 | end 94 | end 95 | 96 | local pf, pt = self.data[ll].f, self.data[rr].f 97 | if pf == 0 or pt == 0 then return 0 end 98 | 99 | local pf, pt = math.log(pf), math.log(pt) 100 | local fro, til = self.data[ll].t, self.data[rr].t 101 | 102 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 103 | end 104 | 105 | function praatPitch:pitchFromArray(arr, dx, x1, ceiling) -- constructor, short text format, 1 candidate 106 | assert(type(arr) == "table" and #arr > 0) 107 | assert(dx and dx > 0) 108 | x1 = x1 or 0 109 | ceiling = ceiling or 1000 110 | 111 | local o = {} 112 | setmetatable(o, self) 113 | self.__index = self 114 | 115 | local data, header = {}, {} 116 | 117 | header["fileType"] = "ooTextFile" 118 | header["objectClass"] = "Pitch 1" 119 | header["maxnCandidates"] = 1 120 | header["dx"] = dx 121 | header["x1"] = x1 122 | header["ceiling"] = ceiling 123 | header["nx"] = #arr 124 | header["xmin"] = 0 125 | header["xmax"] = (header.nx - 1) * header.dx + header.x1 126 | 127 | for i = 1, header.nx do 128 | local pitch = { i = i } 129 | pitch.t = (i - 1) * header.dx + header.x1 130 | pitch.f = arr[i] 131 | 132 | table.insert(data, pitch) 133 | end; 134 | 135 | o.header = header 136 | o.data = data 137 | return o 138 | end 139 | 140 | function praatPitch:savePitch(fnam) -- short text format 141 | local data, header = self.data, self.header 142 | assert(#data == header.nx) 143 | local fi = io.open(fnam, "w") 144 | 145 | fi:write('File type = "ooTextFile"\n') 146 | fi:write('Object class = "Pitch 1"\n') 147 | fi:write("\n") 148 | fi:write(header.xmin.."\n") 149 | fi:write(header.xmax.."\n") 150 | fi:write(header.nx.."\n") 151 | fi:write(header.dx.."\n") 152 | fi:write(header.x1.."\n") 153 | fi:write(header.ceiling.."\n") 154 | fi:write(header.maxnCandidates.."\n") 155 | 156 | for _, pitch in ipairs(data) do 157 | fi:write("1.0\n") -- intensity 158 | fi:write("1\n") -- candidates 159 | 160 | local f = pitch.f 161 | if f < 20 then f = 0 end 162 | 163 | fi:write(f.."\n") 164 | if pitch.f == 0 then 165 | fi:write("0\n") 166 | else 167 | fi:write("1.0\n") 168 | end 169 | end; 170 | fi:close() 171 | end 172 | 173 | end -- end of pitch class 174 | -- filter response 175 | local response = { 176 | -0.000000635231500975,0.000000000000000000,0.000000807065703388,0.000001770470789187,0.000002862668796963,0.000004043973129273,0.000005263135141289,0.000006458630332991, 177 | 0.000007560660763757,0.000008493844917658,0.000009180527453478,0.000009544602392788,0.000009515706298425,0.000009033604903976,0.000008052569468787,0.000006545519715146, 178 | 0.000004507700180840,0.000001959657484146,-0.000001050701779940,-0.000004447168638887,-0.000008125565053328,-0.000011955466249406,-0.000015783368833145,-0.000019437297175434, 179 | -0.000022732771004509,-0.000025479985195950,-0.000027491981631430,-0.000028593526140439,-0.000028630344442158,-0.000027478323070216,-0.000025052247632035,-0.000021313634706140, 180 | -0.000016277212946222,-0.000010015639561057,-0.000002662072919579,0.000005589704583729,0.000014487840869020,0.000023727768724614,0.000032960369199691,0.000041802827672183, 181 | 0.000049851936900116,0.000056699472558167,0.000061949144497833,0.000065234517196924,0.000066237201397718,0.000064704551186687,0.000060466061486024,0.000053447653924649, 182 | 0.000043683066991812,0.000031321630553238,0.000016631805028841,0.000000000000000000,-0.000018075647679758,-0.000036996665044174,-0.000056080721670673,-0.000074582891849405, 183 | -0.000091721171156943,-0.000106705499752311,-0.000118769342494394,-0.000127202699866900,-0.000131385282959422,-0.000130818488750848,-0.000125154765709458,-0.000114222969587942, 184 | -0.000098048378534171,-0.000076866166233212,-0.000051127320223890,-0.000021496235694864,0.000011160493702040,0.000045794236278740,0.000081203224357188,0.000116069615914566, 185 | 0.000149003761450155,0.000178594364830361,0.000203462901228582,0.000222320376246073,0.000234024292246158,0.000237633543550586,0.000232458901829789,0.000218106784302957, 186 | 0.000194514124447651,0.000161972388301211,0.000121139095688357,0.000073035607395202,0.000019030415069590,-0.000039192294572717,-0.000099678454109958,-0.000160263365906606, 187 | -0.000218642854177836,-0.000272454219241792,-0.000319364381367093,-0.000357162163602133,-0.000383851321951806,-0.000397740706324425,-0.000397527842231268,-0.000382372272237852, 188 | -0.000351955193447266,-0.000306522273000218,-0.000246907011915500,-0.000174532646759526,-0.000091391310963566,0.000000000000000001,0.000096666230806742,0.000195262492127315, 189 | 0.000292181455766413,0.000383676794573937,0.000465998749419760,0.000535537224540372,0.000588967276351427,0.000623391497831982,0.000636473633847676,0.000626557808704293, 190 | 0.000592768014645742,0.000535082999477404,0.000454382394847245,0.000352460826998179,0.000232007823519517,0.000096552539339326,-0.000049626367513300,-0.000201624021141629, 191 | -0.000354062724990313,-0.000501271113883708,-0.000637483500467720,-0.000757052808771862,-0.000854669645050693,-0.000925579459950539,-0.000965789440893040,-0.000972256764571389, 192 | -0.000943050151111631,-0.000877477296511540,-0.000776171708820553,-0.000641133714020658,-0.000475721895343966,-0.000284592939029649,-0.000073589724346871,0.000150420548268818, 193 | 0.000379754420565795,0.000606160738351136,0.000821102226032318,0.001016060998716548,0.001182857620521191,0.001313972336440665,0.001402856510521515,0.001444222134474215, 194 | 0.001434297552915503,0.001371038292830095,0.001254283077148637,0.001085846719713366,0.000869543598347635,0.000611137725055624,0.000318218003737925,-0.000000000000000001, 195 | -0.000332941651196247,-0.000669003308377554,-0.000995935339632639,-0.001301266193323235,-0.001572753586790662,-0.001798847271705102,-0.001969146740776351,-0.002074836718825078, 196 | -0.002109083374209074,-0.002067374915592883,-0.001947791602506744,-0.001751192170912955,-0.001481306208374410,-0.001144725036065497,-0.000750787074629070,-0.000311357377446786, 197 | 0.000159495116815019,0.000645915166232399,0.001130762417576187,0.001596181463671069,0.002024222899064806,0.002397494910887145,0.002699822987097212,0.002916894106585375, 198 | 0.003036861367070640,0.003050885452886233,0.002953590661669655,0.002743415381800996,0.002422839894815699,0.001998478091739393,0.001481024033024778,0.000885049115939833, 199 | 0.000228650785950638,-0.000467040932550771,-0.001178487490196913,-0.001880479742801444,-0.002546968333883720,-0.003151960968744115,-0.003670457627499832,-0.004079392263907290, 200 | -0.004358548046723229,-0.004491412808395003,-0.004465942123839048,-0.004275199363086006,-0.003917845118442551,-0.003398452531787279,-0.002727630133555675,-0.001921939707990868, 201 | -0.001003603242675857,0.000000000000000002,0.001057038061377879,0.002132117125215862,0.003187336338928249,0.004183477969228678,0.005081298623057721,0.005842882606635942, 202 | 0.006433014986850680,0.006820529712474388,0.006979587360403019,0.006890837761805883,0.006542424954139873,0.005930795566676460,0.005061276798596616,0.003948396460298771, 203 | 0.002615924945496772,0.001096627268712525,-0.000568277809374028,-0.002329945313176427,-0.004133002699458318,-0.005916895044360736,-0.007617443707363696,-0.009168577759578325, 204 | -0.010504190916985644,-0.011560071510704187,-0.012275849352066937,-0.012596901359766472,-0.012476157606952667,-0.011875751059073609,-0.010768457691091561,-0.009138878819350476, 205 | -0.006984324225550714,-0.004315362801171072,-0.001156016765292844,0.002456414268920778,0.006471902292442096,0.010828604807581575,0.015454124505438985,0.020267085022786714, 206 | 0.025178983301330535,0.030096270365784036,0.034922604992916328,0.039561219003367835,0.043917328964289022,0.047900527080882035,0.051427084055496591,0.054422098717605220, 207 | 0.056821433226337009,0.058573378506176700,0.059640002123555226,0.059998139821041983} 208 | 209 | ------------ filter class 210 | local FIRfilter = {} -- class 211 | 212 | do 213 | 214 | function FIRfilter:new(response) 215 | local o = {} 216 | setmetatable(o, self) 217 | self.__index = self 218 | 219 | assert(#response > 1) 220 | 221 | local coefs = {} 222 | for i = 1, #response do 223 | table.insert(coefs, response[i]) 224 | end 225 | -- mirrored second half 226 | for i = #response - 1, 1, -1 do 227 | table.insert(coefs, response[i]) 228 | end 229 | 230 | o.coefs = coefs 231 | o.taps = 2 * (#response - 1) + 1 232 | assert(o.taps == #coefs) 233 | return o 234 | end 235 | 236 | function FIRfilter:filter(data) -- do the filtering 237 | local out, buff = {}, {} 238 | local smptr = 1 239 | -- init buffer 240 | local d = data[1] 241 | for i = 1, self.taps do 242 | table.insert(buff, d) 243 | end 244 | -- do one sample 245 | local function sample() 246 | local d 247 | if smptr <= #data then 248 | d = data[smptr] 249 | smptr = smptr + 1 250 | else 251 | d = data[#data] 252 | end 253 | 254 | table.insert(buff, 1, d) -- new sample 255 | table.remove(buff) -- last sample 256 | -- convolution 257 | local sum = 0 258 | for i = 1, self.taps do 259 | sum = sum + self.coefs[i] * buff[i] 260 | end 261 | 262 | return sum 263 | end 264 | -- prefilter to be zero phase 265 | for i = 1, math.floor(self.taps / 2) do 266 | sample() 267 | end 268 | 269 | for i = 1, #data do 270 | local d = sample() 271 | table.insert(out, d) 272 | end 273 | 274 | return out 275 | end 276 | 277 | end -- end of filter class 278 | 279 | local function getProjectPathName() 280 | local projectFileName = SV:getProject():getFileName() 281 | if not projectFileName then return end 282 | 283 | local projectName, projectDir 284 | projectFileName = projectFileName:gsub("\\", "/") 285 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 286 | if not projectDir or not projectName then error(SV:T("project dir or name not found, save your project first")) end 287 | 288 | return projectName, projectDir 289 | end 290 | 291 | local function fileExists(fname) 292 | local fi = io.open(fname) 293 | if not fi then return end 294 | fi:close() 295 | return true 296 | end 297 | 298 | local function process() 299 | -- pitch file in project folder 300 | local projectName, projectDir = getProjectPathName() 301 | local pitchFileName = projectDir..projectName.."_Pitch.txt" 302 | -- show info box 303 | if not SV:showOkCancelBox(SV:T("Filter Pitch"), SV:T("Low pass filter _Pitch.txt, old file become _unfiltered_Pitch.txt")) then return end -- cancel pressed 304 | 305 | if not fileExists(pitchFileName) then 306 | SV:showMessageBox(SV:T("Error"), SV:T("Cannot open pitch file").." '"..pitchFileName.."'") 307 | return 308 | end 309 | 310 | local pitch = praatPitch:loadPitch(pitchFileName) -- pitch instance 311 | if not pitch then 312 | SV:showMessageBox(SV:T("Error"), SV:T("wrong pitch file format")) 313 | return 314 | end 315 | 316 | local fp, ifp = {}, {} 317 | local t = pitch.header.xmin 318 | -- sampling 319 | while t <= pitch.header.xmax do 320 | local p = pitch:getPitch(t) 321 | table.insert(fp, p) 322 | table.insert(ifp, p) 323 | t = t + 0.002 324 | end 325 | -- interpolate unvoiced 326 | local i = 1 327 | repeat 328 | while i <= #ifp and ifp[i] ~= 0 do i = i + 1 end 329 | if i >= #ifp then break end 330 | 331 | local j = i 332 | while j <= #ifp and ifp[j] == 0 do j = j + 1 end 333 | 334 | local left, right = ifp[i - 1] or ifp[j + 1], ifp[j + 1] or ifp[i - 1] 335 | assert(left and right) 336 | 337 | if left > 0 and right > 0 then 338 | for k = i, j do 339 | local pf, pt = math.log(left), math.log(right) 340 | ifp[k] = math.exp(pf + (pt - pf) / (j - i + 2) * (k - i + 1)) 341 | if j + 1 - i <= 2 then -- short unvoiced -> voiced 342 | fp[k] = ifp[k] 343 | end 344 | end 345 | end 346 | 347 | i = j + 1 348 | until false 349 | 350 | local filt = FIRfilter:new(response) 351 | ffilt = filt:filter(ifp) 352 | -- set unvoiced back 353 | for i, f in ipairs(fp) do 354 | if f == 0 then 355 | ffilt[i] = 0 356 | end 357 | end 358 | 359 | local fpitch = praatPitch:pitchFromArray(ffilt, 0.002, pitch.header.x1*0.002/pitch.header.dx, pitch.header.ceiling) 360 | pitch:savePitch(projectDir..projectName.."_unfiltered_Pitch.txt") -- backup of original 361 | fpitch:savePitch(pitchFileName) -- filtered 362 | 363 | SV:showMessageBox(SV:T("Info"), SV:T("Done filtering")) 364 | end 365 | 366 | function main() 367 | process() 368 | SV:finish() 369 | end 370 | -------------------------------------------------------------------------------- /notesFromTextGrid.lua: -------------------------------------------------------------------------------- 1 | SCRIPT_TITLE = "RV Notes from TextGrid" 2 | -- Ver.1 - loads notes from Pratt textGrid object, pitch is quantized from pitch object 3 | -- Ver.2 - added support for loading a pitch encoded in the textGrid lyrics (without pitch quantization), 4 | -- encoding can be generated by "RV Notes to TextGrid" 5 | -- the pitch is encoded as MIDI's note index minus 69 offset (ie 0 = A4) 6 | -- Ver.3 - updat to ver 1.9.0 of SynthV Pro, should work with 1.8 too 7 | -- exclamation mark functionality has been (temporarily) removed due some bug in 1.9.0 version 8 | -- refactored pitch dev timing to consume less resources 9 | -- added option to load phoneme labeled segments (adds a dot before lyrics of every note) 10 | 11 | function getClientInfo() 12 | return { 13 | name = SV:T(SCRIPT_TITLE), 14 | author = "Hataori@protonmail.com", 15 | category = "Real Voice", 16 | versionNumber = 3, 17 | minEditorVersion = 0x010800 18 | } 19 | end 20 | 21 | local NOTES = {'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B'} 22 | 23 | local SCALES = { 24 | ['chroma'] = 'C-C#-D-D#-E-F-F#-G-G#-A-Bb-B-', 25 | ['C/a'] = 'C-D-E-F-G-A-B-', 26 | ['C#/Db/bb'] = 'C#-D#-F-F#-G#-Bb-C-', 27 | ['D/b'] = 'D-E-F#-G-A-B-C#-', 28 | ['Eb/c'] = 'D#-F-G-G#-Bb-C-D-', 29 | ['E/c#'] = 'E-F#-G#-A-B-C#-D#-', 30 | ['F/d'] = 'F-G-A-Bb-C-D-E-', 31 | ['F#/Gb/d#/eb'] = 'F#-G#-Bb-B-C#-D#-F-', 32 | ['G/e'] = 'G-A-B-C-D-E-F#-', 33 | ['Ab/f'] = 'G#-Bb-C-C#-D#-F-G-', 34 | ['A/f#'] = 'A-B-C#-D-E-F#-G#-', 35 | ['Bb/g'] = 'Bb-C-D-D#-F-G-A-', 36 | ['B/Cb/g#'] = 'B-C#-D#-E-F#-G#-Bb-' 37 | } 38 | 39 | local inputForm = { 40 | title = SV:T("Notes from Praat TextGrid and Pitch"), 41 | message = SV:T("Timing and pitch to be loaded as notes into current track"), 42 | buttons = "OkCancel", 43 | widgets = { 44 | { 45 | name = "scale", type = "ComboBox", 46 | label = SV:T("Scale (Maj/Min)"), 47 | choices = {"chroma", "C/a", "C#/Db/bb", "D/b", "Eb/c", "E/c#", "F/d", "F#/Gb/d#/eb", "G/e", "Ab/f", "A/f#", "Bb/g", "B/Cb/g#"}, 48 | default = 0 49 | }, 50 | { 51 | name = "loadPitchCheck", type = "CheckBox", 52 | text = SV:T("Load pitch automation"), 53 | default = false 54 | }, 55 | { 56 | name = "phonemesInLyrics", type = "CheckBox", 57 | text = SV:T("TextGrid is marked as phonemes (add a dot before every lyrics)"), 58 | default = false 59 | } 60 | } 61 | } 62 | 63 | ------------ Praat pitch 64 | local praatPitch = {} -- class 65 | 66 | do 67 | 68 | local PitchHeader = { 69 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 70 | {n="Object_class", v="Object class = \"Pitch 1\"", t="del"}, 71 | {t="del"}, 72 | {n="xmin", t="num"}, 73 | {n="xmax", t="num"}, 74 | {n="nx", t="num"}, 75 | {n="dx", t="num"}, 76 | {n="x1", t="num"}, 77 | {n="ceiling", t="num"}, 78 | {n="maxnCandidates", t="num"} 79 | } 80 | 81 | function praatPitch:loadPitch(fnam) -- constructor, short text format 82 | local o = {} 83 | setmetatable(o, self) 84 | self.__index = self 85 | 86 | local data, header = {}, {} 87 | 88 | local fi = io.open(fnam) 89 | assert(fi, "cannot open pitch file") 90 | for i = 1, #PitchHeader do 91 | local lin = fi:read("*l") 92 | local h = PitchHeader[i] 93 | 94 | if h.v then 95 | if lin ~= h.v then return end 96 | elseif h.t == "num" then 97 | lin = tonumber(lin) 98 | end 99 | 100 | if h.n and h.t ~= "del" then 101 | header[h.n] = lin 102 | end 103 | end 104 | 105 | header["fileType"] = "ooTextFile" 106 | header["objectClass"] = "Pitch 1" 107 | assert(header.nx, "no nx in pitch file") 108 | 109 | for i = 1, header.nx do 110 | local pitch = { i = i } 111 | pitch.t = (i - 1) * header.dx + header.x1 112 | 113 | local int = fi:read("*n", "*l") -- intensity 114 | local cand = fi:read("*n", "*l") -- candidates 115 | assert(cand, "no candidates in pitch file") 116 | 117 | for k = 1, cand do 118 | local f = fi:read("*n", "*l") 119 | if k == 1 then 120 | pitch.f = f 121 | end 122 | fi:read("*n", "*l") 123 | end 124 | 125 | table.insert(data, pitch) 126 | end; 127 | fi:close() 128 | 129 | o.header = header 130 | o.data = data 131 | return o 132 | end 133 | 134 | function praatPitch:getPitch(t) -- ret: f0 [Hz] 135 | if t < self.data[1].t then return 0 end 136 | if t > self.data[#self.data].t then return 0 end 137 | 138 | local ll, rr = 1, #self.data 139 | while (rr-ll) > 1 do 140 | local cc = math.floor((rr + ll) / 2) 141 | if t <= self.data[cc].t then 142 | rr = cc 143 | else 144 | ll = cc 145 | end 146 | end 147 | 148 | local pf, pt = self.data[ll].f, self.data[rr].f 149 | if pf == 0 or pt == 0 then return 0 end 150 | 151 | local pf, pt = math.log(pf), math.log(pt) 152 | local fro, til = self.data[ll].t, self.data[rr].t 153 | 154 | return math.exp(pf + (pt - pf) / (til - fro) * (t - fro)) 155 | end 156 | 157 | end -- end class 158 | 159 | ------------ Praat textGrid 160 | local praatTextGrid = {} -- class 161 | 162 | do 163 | 164 | local TextGridHeader = { 165 | {n="File_type", v="File type = \"ooTextFile\"", t="del"}, 166 | {n="Object_class", v="Object class = \"TextGrid\"", t="del"}, 167 | {t="del"}, 168 | {n="xmin", t="num"}, 169 | {n="xmax", t="num"}, 170 | {v="", t="del"}, 171 | {n="tireCnt", t="num"}, 172 | {n="tireType", v="\"IntervalTier\""}, 173 | {n="tireName"}, 174 | {n="txmin", t="num"}, 175 | {n="txmax", t="num"}, 176 | {n="nx", t="num"}, 177 | } 178 | 179 | function praatTextGrid:loadFirstIntervalGridTier(fnam) -- constructor, short text format 180 | local o = {} 181 | setmetatable(o, self) 182 | self.__index = self 183 | 184 | local data, header = {}, {} 185 | 186 | local fi = io.open(fnam) 187 | assert(fi, "cannot open grid file") 188 | for i = 1, #TextGridHeader do 189 | local lin = fi:read("*l") 190 | local h = TextGridHeader[i] 191 | 192 | if h.v then 193 | if lin ~= h.v then return end 194 | elseif h.t == "num" then 195 | lin = tonumber(lin) 196 | end 197 | 198 | if h.n and h.t ~= "del" then 199 | header[h.n] = lin 200 | end 201 | end 202 | 203 | header["fileType"] = "ooTextFile" 204 | header["objectClass"] = "TextGrid" 205 | assert(header.nx, "no nx in pitch file") 206 | 207 | for i = 1, header.nx do 208 | local interval = {} 209 | 210 | local time_from = fi:read("*n", "*l") 211 | local time_to = fi:read("*n", "*l") 212 | local txt = fi:read("*l") 213 | txt = txt:match("\"([^\"]+)\"") or "" 214 | 215 | table.insert(data, { 216 | fr = time_from, 217 | to = time_to, 218 | tx = txt 219 | }) 220 | end; 221 | fi:close() 222 | 223 | o.header = header 224 | o.data = data 225 | return o 226 | end 227 | 228 | end -- end class 229 | 230 | --------- qunatization 231 | 232 | function HzToHalftone(hz) 233 | if hz<=0 then return end 234 | return 12 * math.log(hz / 440) / math.log(2) 235 | end 236 | 237 | -- halftone number from HzToHalftone, 0 = 440 Hz, scale string from SCALES 238 | function isInScale(ht, scale) 239 | local cbase = math.fmod(ht + 57, 12) -- every C = 0 240 | local nam = NOTES[cbase + 1] 241 | return string.find(scale, nam.."-", 1, true) 242 | end 243 | 244 | -- quantize to scale 245 | function quantizeNote(hz, scale) 246 | local ht = HzToHalftone(hz) 247 | if not ht then return end 248 | 249 | local qht = math.floor(ht + 0.5) 250 | if isInScale(qht, scale) then return qht end 251 | 252 | local lht, hht = qht - 1, qht + 1 253 | while not isInScale(lht, scale) do 254 | lht = lht - 1 255 | end 256 | 257 | while not isInScale(hht, scale) do 258 | hht = hht + 1 259 | end 260 | 261 | if math.abs(ht - lht) < math.abs(ht - hht) then 262 | return lht 263 | else 264 | return hht 265 | end 266 | end 267 | 268 | local function getProjectPathName() 269 | local projectFileName = SV:getProject():getFileName() 270 | if not projectFileName then return end 271 | 272 | local projectName, projectDir 273 | projectFileName = projectFileName:gsub("\\", "/") 274 | projectDir, projectName = projectFileName:match("^(.*/)([^/]+)%.svp$") 275 | return projectName, projectDir 276 | end 277 | 278 | local function fileExists(fname) 279 | local fi = io.open(fname) 280 | if not fi then return end 281 | fi:close() 282 | return true 283 | end 284 | 285 | local function process() 286 | local sver = SV:getHostInfo().hostVersionNumber -- SynthV version 287 | -- pitch and grid files in project folder 288 | local projectName, projectDir = getProjectPathName() 289 | if not projectDir or not projectName then 290 | SV:showMessageBox(SV:T("Error"), SV:T("Project dir or name not found, save your project first")) 291 | return 292 | end 293 | 294 | local pitchFileName = projectDir..projectName.."_Pitch.txt" 295 | local gridFileName = projectDir..projectName.."_TextGrid.txt" 296 | 297 | if not fileExists(gridFileName) then 298 | SV:showMessageBox(SV:T("Error"), SV:T("Cannot open textGrid file").." '"..gridFileName.."' "..SV:T("(_TextGrid.txt) & pitch (_Pitch.txt) files are expected to be in the project dir")) 299 | return 300 | end 301 | 302 | if not fileExists(pitchFileName) then 303 | SV:showMessageBox(SV:T("Error"), SV:T("Cannot open pitch file").." '"..pitchFileName.."' "..SV:T("(_TextGrid.txt) & pitch (_Pitch.txt) files are expected to be in the project dir")) 304 | return 305 | end 306 | 307 | local grid = praatTextGrid:loadFirstIntervalGridTier(gridFileName) -- textgrid instance 308 | if not grid then 309 | SV:showMessageBox(SV:T("Error"), SV:T("Wrong textgrid file format, save it as SHORT text")) 310 | return 311 | end 312 | 313 | local pitch = praatPitch:loadPitch(pitchFileName) -- pitch instance 314 | if not pitch then 315 | SV:showMessageBox(SV:T("Error"), SV:T("Wrong pitch file format, save it as SHORT text")) 316 | return 317 | end 318 | 319 | local noPitchEnc = 0 -- number of notes with pitch not encoded 320 | for _, int in ipairs(grid.data) do 321 | if int.tx and int.tx ~= "" and not int.tx:find("!", 1, true) and not int.tx:match("%([%d-]+%)") then 322 | noPitchEnc = noPitchEnc + 1 323 | end 324 | end 325 | 326 | if noPitchEnc == 0 then table.remove(inputForm.widgets, 1) end -- scale not needed 327 | -- input dialog 328 | local dlgResult = SV:showCustomDialog(inputForm) 329 | if not dlgResult.status then return end -- cancel pressed 330 | 331 | local scaleName = "chroma" 332 | if noPitchEnc > 0 then 333 | scaleName = inputForm.widgets[1].choices[dlgResult.answers.scale + 1] 334 | end 335 | local qScale = SCALES[scaleName] 336 | assert(qScale, "wrong scale") 337 | 338 | local timeAxis = SV:getProject():getTimeAxis() 339 | local scope = SV:getMainEditor():getCurrentGroup() 340 | local group = scope:getTarget() 341 | -- local veam = group:getParameter("vibratoEnv") -- vibrato envelope for extra events 342 | -- veam:removeAll() 343 | 344 | local notes, lastNote = {}, nil 345 | for _, int in ipairs(grid.data) do 346 | local frb, frt = timeAxis:getBlickFromSeconds(int.fr), timeAxis:getBlickFromSeconds(int.to) 347 | -- normalize spaces 348 | if int.tx then 349 | int.tx = int.tx:gsub("_", "") 350 | int.tx = int.tx:match("^%s*(.-)%s*$") or "" 351 | else 352 | int.tx = "" 353 | end 354 | 355 | if int.tx and int.tx:find("!", 1, true) then 356 | -- this functionali ty removed due to some bug in vibrato envelope 357 | SV:showMessageBox(SV:T("Error"), SV:T("Exclamation mark functionality has been (temporarily) removed due some bug in 1.9.0 version")) 358 | return 359 | -- veam:add(frb, 1) 360 | -- if lastNote then 361 | -- lastNote.en = frt 362 | -- end 363 | elseif int.tx and int.tx ~= "" then 364 | local txt, pit = int.tx:match("^(.-)%s*%(([%d-]+)%)$") 365 | 366 | if pit then -- pitch encoded in textGrid 367 | lastNote = {st = frb, en = frt, pitch = tonumber(pit) + 69, lyr = txt} 368 | table.insert(notes, lastNote) 369 | -- veam:add(frb, 1) 370 | else -- pitch quantization 371 | local med, cnt, unvoc = {}, 0, 0 372 | local t = int.fr 373 | while t <= int.to do 374 | local f0 = pitch:getPitch(t) 375 | if f0 > 50 then 376 | local qn = quantizeNote(f0, qScale) 377 | table.insert(med, qn) 378 | else 379 | unvoc = unvoc + 1 380 | end 381 | 382 | t = t + 0.001 383 | cnt = cnt + 1 384 | end 385 | 386 | if #med > 2 and (#med / cnt) > 0.5 then -- more than 50% voiced length 387 | table.sort(med) 388 | med = med[math.floor(#med / 2) + 1] -- pitch median 389 | 390 | lastNote = {st = frb, en = frt, pitch = med + 69, lyr = int.tx} 391 | table.insert(notes, lastNote) 392 | -- veam:add(frb, 1) 393 | else 394 | lastNote = {st = frb, en = frt, lyr = int.tx} 395 | table.insert(notes, lastNote) 396 | -- veam:add(frb, 1) 397 | end 398 | end -- end if pitch 399 | else 400 | lastNote = nil 401 | end -- if int.tx 402 | end -- for 403 | -- remove old notes 404 | local ncnt = group:getNumNotes() 405 | if ncnt > 0 then 406 | for i = ncnt, 1, -1 do 407 | group:removeNote(i) 408 | end 409 | end 410 | 411 | local addDot = dlgResult.answers.phonemesInLyrics 412 | -- create notes 413 | for i, nt in ipairs(notes) do 414 | local note = SV:create("Note") 415 | note:setTimeRange(nt.st, nt.en - nt.st) 416 | 417 | if sver >= 0x010900 then 418 | note:setPitchAutoMode(0) -- manual pitch mode 419 | end 420 | 421 | local pitch = nt.pitch 422 | if not pitch then 423 | pitch = (i < #notes and notes[i + 1].pitch) or (i > 1 and notes[i - 1].pitch) or 69 424 | end 425 | note:setPitch(pitch) 426 | 427 | local lyr = nt.lyr or "" 428 | 429 | lyr = lyr:gsub("%d", "") 430 | lyr = lyr:lower() 431 | 432 | if addDot then 433 | lyr = "."..lyr 434 | end 435 | 436 | note:setLyrics(lyr) 437 | 438 | group:addNote(note) 439 | end 440 | -- load pitch automation 441 | if dlgResult.answers.loadPitchCheck then 442 | local am = group:getParameter("pitchDelta") -- pitch automation 443 | am:removeAll() 444 | 445 | scope:setVoice({ 446 | tF0Left = 0, 447 | tF0Right = 0, 448 | dF0Left = 0, 449 | dF0Right = 0, 450 | dF0Vbr = 0 451 | }) 452 | 453 | local noteCnt = group:getNumNotes() 454 | 455 | for i = 1, noteCnt do 456 | local note = group:getNote(i) 457 | local npitch = note:getPitch() 458 | local ncents = 100 * (npitch - 69) -- A4 459 | 460 | local blOnset, blEnd = note:getOnset(), note:getEnd() 461 | 462 | local tons = timeAxis:getSecondsFromBlick(blOnset) -- start time 463 | local tend = timeAxis:getSecondsFromBlick(blEnd) -- end time 464 | 465 | local tempo = timeAxis:getTempoMarkAt(blOnset) 466 | local compensation = tempo.bpm * 6.3417442 467 | local t_step = math.max(SV:blick2Seconds(SV:quarter2Blick(1/64), tempo.bpm), 0.01) 468 | 469 | local df, f0 470 | local o10, e10 = tons + 0.010, tend - 0.010 471 | local t = tons + 0.0005 472 | while t < tend - 0.0001 do 473 | f0 = pitch:getPitch(t) 474 | if f0 > 50 then -- voiced 475 | df = 1200 * math.log(f0/440)/math.log(2) - ncents -- delta f0 in cents 476 | am:add(timeAxis:getBlickFromSeconds(t), df) 477 | end 478 | 479 | if t <= o10 or t >= e10 then 480 | t = t + 0.001 481 | else 482 | t = t + t_step -- time step 483 | if t >= e10 then 484 | t = e10 485 | end 486 | end 487 | end 488 | 489 | if i > 1 then 490 | local pnote = group:getNote(i - 1) 491 | local pnpitch = pnote:getPitch() 492 | local pncents = 100 * (pnpitch - 69) -- A4 493 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 494 | 495 | if pblEnd == blOnset then 496 | local pts = am:getPoints(blOnset, timeAxis:getBlickFromSeconds(tons + 0.010)) 497 | local pdif = ncents - pncents 498 | 499 | for _, pt in ipairs(pts) do 500 | local b, v = pt[1], pt[2] 501 | local t = timeAxis:getSecondsFromBlick(b) - tons 502 | local cor = 1 - (1 / (1 + math.exp(-compensation * t))) 503 | am:add(b, v + pdif * cor) 504 | end 505 | end 506 | end 507 | 508 | if i < noteCnt then 509 | local pnote = group:getNote(i + 1) 510 | local pnpitch = pnote:getPitch() 511 | local pncents = 100 * (pnpitch - 69) -- A4 512 | local pblOnset, pblEnd = pnote:getOnset(), pnote:getEnd() 513 | 514 | if blEnd == pblOnset then 515 | local pts = am:getPoints(timeAxis:getBlickFromSeconds(tend - 0.010), blEnd - 1) 516 | local pdif = pncents - ncents 517 | 518 | for _, pt in ipairs(pts) do 519 | local b, v = pt[1], pt[2] 520 | local t = timeAxis:getSecondsFromBlick(b) - tend 521 | local cor = 1 / (1 + math.exp(-compensation * t)) 522 | am:add(b, v - pdif * cor) 523 | end 524 | end 525 | end 526 | 527 | am:simplify(blOnset, blEnd, 0.0001) 528 | end 529 | end 530 | end 531 | 532 | function main() 533 | process() 534 | SV:finish() 535 | end 536 | --------------------------------------------------------------------------------