--[[ TranscriptEditor.lua - Transcript editor UI ]]-- TranscriptEditor = Polo { TITLE = 'Edit Segment', WIDTH = 500, HEIGHT = 500, MIN_CONTENT_WIDTH = 375, BUTTON_WIDTH = 120, WORDS_PER_LINE = 5, ZOOM_LEVEL = { NONE = { value = "none", description = "None" }, WORD = { value = "word", description = "Word" }, SEGMENT = { value = "segment", description = "Segment" }, }, } function TranscriptEditor:init() assert(self.transcript, 'missing transcript') self.editing = nil self.is_open = false self.sync_time_selection = false self.zoom_level = self.ZOOM_LEVEL.NONE.value self.key_bindings = self:make_key_bindings() end function TranscriptEditor:make_key_bindings() return KeyMap.new({ [ImGui.Key_LeftArrow()] = function () self:edit_word(self.editing.word_index - 1) end, [ImGui.Key_RightArrow()] = function () self:edit_word(self.editing.word_index + 1) end, }) end function TranscriptEditor:edit_segment(segment, index) self.editing = { segment = segment, words = {}, index = index, text = segment:get('text'), } for i, word in pairs(segment.words) do self.editing.words[i] = word:copy() end self:edit_word(1) end function TranscriptEditor:edit_word(index) if index < 1 or index > #self.editing.words then return end local word = self.editing.words[index] self.editing.word = word self.editing.word_index = index if self.sync_time_selection then self:update_time_selection() self:zoom(self.zoom_level) end end function TranscriptEditor:render() if not self.editing then return end local opening = not self.is_open if opening then self:_open() end local center = {ImGui.Viewport_GetCenter(ImGui.GetWindowViewport(ctx))} ImGui.SetNextWindowPos(ctx, center[1], center[2], ImGui.Cond_Appearing(), 0.5, 0.5) ImGui.SetNextWindowSize(ctx, self.WIDTH, self.HEIGHT, ImGui.Cond_FirstUseEver()) if ImGui.BeginPopupModal(ctx, self.TITLE, true, ImGui.WindowFlags_AlwaysAutoResize()) then app:trap(function () self.key_bindings:react() self:render_content() end) ImGui.EndPopup(ctx) else self:_close() end end function TranscriptEditor:render_content() if self.editing.word then self:render_word_navigation() self:render_separator() end local edit_requested = self:render_words() if self.editing.word then self:render_separator() self:render_word_actions() self:render_word_inputs() end if edit_requested then self:edit_word(edit_requested) end self:render_separator() if ImGui.Button(ctx, 'Save', self.BUTTON_WIDTH, 0) then self:handle_save() self:_close() end ImGui.SameLine(ctx) if ImGui.Button(ctx, 'Cancel', self.BUTTON_WIDTH, 0) then self:_close() end end function TranscriptEditor:render_word_navigation() local words = self.editing.words local word_index = self.editing.word_index local num_words = #words local spacing = ImGui.GetStyleVar(ctx, ImGui.StyleVar_ItemInnerSpacing()) local disable_if = ReaUtil.disabler(ctx, app.onerror) ImGui.PushButtonRepeat(ctx, true) app:trap(function () if ImGui.ArrowButton(ctx, '##left', ImGui.Dir_Left()) then self:edit_word(self.editing.word_index - 1) end ImGui.SameLine(ctx, 0, spacing) if ImGui.ArrowButton(ctx, '##right', ImGui.Dir_Right()) then self:edit_word(self.editing.word_index + 1) end end) ImGui.PopButtonRepeat(ctx) ImGui.SameLine(ctx) ImGui.AlignTextToFramePadding(ctx) ImGui.Text(ctx, 'Word ' .. word_index .. ' / ' .. num_words) ImGui.SameLine(ctx) if ImGui.Button(ctx, 'Add') then self:handle_word_add() end app:tooltip('Add word after current word') ImGui.SameLine(ctx, 0, spacing) disable_if(num_words <= 1, function() if ImGui.Button(ctx, 'Delete') then self:handle_word_delete() end end) app:tooltip('Delete current word') ImGui.SameLine(ctx, 0, spacing) if ImGui.Button(ctx, 'Split') then self:handle_word_split() end app:tooltip('Split current word into two words') ImGui.SameLine(ctx, 0, spacing) disable_if(word_index >= num_words, function() if ImGui.Button(ctx, 'Merge') then self:handle_word_merge() end end) app:tooltip('Merge current word with next word') end function TranscriptEditor:render_words() local words = self.editing.words local num_words = #words local spacing = ImGui.GetStyleVar(ctx, ImGui.StyleVar_ItemInnerSpacing()) local edit_requested = nil for i, word in pairs(words) do if self.editing.word_index ~= i then ImGui.PushStyleColor(ctx, ImGui.Col_Button(), 0xffffff33) end app:trap(function() if ImGui.Button(ctx, word.word .. '##' .. i) then edit_requested = i end end) if self.editing.word_index ~= i then ImGui.PopStyleColor(ctx) end if i < num_words and i % self.WORDS_PER_LINE ~= 0 then ImGui.SameLine(ctx, 0, spacing) end end return edit_requested end function TranscriptEditor:render_word_inputs() self:render_word_input() if self.sync_time_selection then local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false) local offset = Transcript.calculate_offset(self.editing.segment.item, self.editing.segment.take) self.editing.word.start = sel_start - offset self.editing.word.end_ = sel_end - offset end self:render_time_input('start', self.editing.word.start, function (time) self.editing.word.start = time end) self:render_time_input('end', self.editing.word.end_, function (time) self.editing.word.end_ = time end) self:render_score_input() end function TranscriptEditor:render_word_input() local rv, value = ImGui.InputText(ctx, 'word', self.editing.word.word) if rv then value = value:gsub('^%s*(.-)%s*$', '%1') if #value > 0 then self.editing.word.word = value end end end function TranscriptEditor:render_time_input(label, value, callback) local value_str = reaper.format_timestr(value, '') local rv, new_value = ImGui.InputText(ctx, label, value_str) if rv then callback(reaper.parse_timestr(new_value)) end end function TranscriptEditor:render_score_input() local color = TranscriptUI.score_color(self.editing.word:score()) if color then ImGui.PushStyleColor(ctx, ImGui.Col_SliderGrab(), color) ImGui.PushStyleColor(ctx, ImGui.Col_SliderGrabActive(), color) end app:trap(function () local rv, value = ImGui.SliderDouble(ctx, 'score', self.editing.word.probability, 0, 1) if rv then self.editing.word.probability = value end end) if color then ImGui.PopStyleColor(ctx, 2) end end function TranscriptEditor:render_icon_button(icon, callback) ImGui.PushFont(ctx, Fonts.icons) app:trap(function () if ImGui.Button(ctx, Fonts.ICON[icon]) then callback() end end) ImGui.PopFont(ctx) end function TranscriptEditor:render_word_actions() self:render_icon_button('play', function () self:update_time_selection() reaper.Main_OnCommand(1016, 0) -- Transport: Stop reaper.Main_OnCommand(40630, 0) -- Go to start of time selection reaper.Main_OnCommand(40044, 0) -- Transport: Play/stop end) app:tooltip('Play word') local spacing = ImGui.GetStyleVar(ctx, ImGui.StyleVar_ItemInnerSpacing()) ImGui.SameLine(ctx, 0, spacing) self:render_icon_button('stop', function () reaper.Main_OnCommand(1016, 0) -- Transport: Stop end) app:tooltip('Stop') ImGui.SameLine(ctx) local rv, value = ImGui.Checkbox(ctx, 'sync time selection', self.sync_time_selection) if rv then self.sync_time_selection = value if value then self:update_time_selection() end end ImGui.SameLine(ctx) ImGui.Text(ctx, 'and') ImGui.SameLine(ctx) self:render_zoom_combo() end function TranscriptEditor:render_zoom_combo() local disable_if = ReaUtil.disabler(ctx, app.onerror) ImGui.SameLine(ctx) ImGui.Text(ctx, "zoom to") ImGui.SameLine(ctx) ImGui.PushItemWidth(ctx, self.BUTTON_WIDTH) app:trap(function() disable_if(not self.sync_time_selection, function() if ImGui.BeginCombo(ctx, "##zoom_level", self.zoom_level) then app:trap(function() for _, zoom in pairs(self.ZOOM_LEVEL) do if ImGui.Selectable(ctx, zoom.description, self.zoom_level == zoom.value) then self.zoom_level = zoom.value self:handle_zoom_change() end end end) ImGui.EndCombo(ctx) end end) end) ImGui.PopItemWidth(ctx) end function TranscriptEditor:offset() return Transcript.calculate_offset(self.editing.segment.item, self.editing.segment.take) end function TranscriptEditor:zoom(zoom_level) -- save current selection local start, end_ = reaper.GetSet_LoopTimeRange(false, true, 0, 0, false) if zoom_level == self.ZOOM_LEVEL.WORD.value then self.editing.word:select_in_timeline(self:offset()) elseif zoom_level == self.ZOOM_LEVEL.SEGMENT.value then self.editing.segment:select_in_timeline(self:offset()) else return end -- View: Zoom time selection reaper.Main_OnCommandEx(40031, 1) -- restore selection reaper.GetSet_LoopTimeRange(true, true, start, end_, false) end function TranscriptEditor:render_separator() ImGui.Dummy(ctx, self.MIN_CONTENT_WIDTH, 0) ImGui.Separator(ctx) ImGui.Dummy(ctx, 0, 0) end function TranscriptEditor:update_time_selection() if self.editing then self.editing.word:select_in_timeline(self:offset()) end end function TranscriptEditor:handle_save() if self.editing then local segment = self.editing.segment segment:set_words(self.editing.words) self.transcript:update() end end function TranscriptEditor:handle_word_add() local words = self.editing.words local word_index = self.editing.word_index table.insert(words, word_index + 1, TranscriptWord.new { word = '...', start = words[word_index].end_, end_ = words[word_index].end_, probability = 1.0 }) self:edit_word(word_index + 1) end function TranscriptEditor:handle_word_delete() local words = self.editing.words local word_index = self.editing.word_index table.remove(words, word_index) local num_words = #words if word_index > num_words then word_index = num_words end self:edit_word(word_index) end function TranscriptEditor:handle_word_split() local words = self.editing.words local word_index = self.editing.word_index TranscriptSegment.split_word(words, word_index) self:edit_word(word_index) end function TranscriptEditor:handle_word_merge() local words = self.editing.words local word_index = self.editing.word_index local num_words = #words if word_index < num_words then TranscriptSegment.merge_words(words, word_index, word_index + 1) self:edit_word(word_index) end end function TranscriptEditor:handle_zoom_change() if self.sync_time_selection then self:zoom(self.zoom_level) end end function TranscriptEditor:_open() ImGui.OpenPopup(ctx, self.TITLE) self.is_open = true end function TranscriptEditor:_close() ImGui.CloseCurrentPopup(ctx) self.editing = nil self.is_open = false end