/********************************************************************** Audacity: A Digital Audio Editor Lyrics.cpp Dominic Mazzoni Vaughan Johnson **********************************************************************/ #include "Lyrics.h" #include #include #include #include #include #include #include "AudioIO.h" #include "Project.h" #include "ProjectWindowBase.h" #include "LabelTrack.h" #include "commands/CommandManager.h" #include "UndoManager.h" #include "ViewInfo.h" BEGIN_EVENT_TABLE(HighlightTextCtrl, wxTextCtrl) EVT_MOUSE_EVENTS(HighlightTextCtrl::OnMouseEvent) END_EVENT_TABLE() HighlightTextCtrl::HighlightTextCtrl(LyricsPanel* parent, wxWindowID id, const wxString& value /* = {} */, const wxPoint& pos /*= wxDefaultPosition*/, const wxSize& size /*= wxDefaultSize*/) : wxTextCtrl(parent, id, // wxWindow* parent, wxWindowID id, value, // const wxString& value = {}, pos, // const wxPoint& pos = wxDefaultPosition, size, // const wxSize& size = wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY | wxTE_RICH | wxTE_RICH2 | wxTE_AUTO_URL | wxTE_NOHIDESEL), //v | wxHSCROLL) mLyricsPanel(parent) { } void HighlightTextCtrl::OnMouseEvent(wxMouseEvent& event) { if (event.ButtonUp()) { long from, to; this->GetSelection(&from, &to); int nCurSyl = mLyricsPanel->GetCurrentSyllableIndex(); int nNewSyl = mLyricsPanel->FindSyllable(from); if (nNewSyl != nCurSyl) { Syllable* pCurSyl = mLyricsPanel->GetSyllable(nNewSyl); auto pProj = FindProjectFromWindow( this ); auto &selectedRegion = ViewInfo::Get( *pProj ).selectedRegion; selectedRegion.setT0( pCurSyl->t ); //v Should probably select to end as in // SelectUtilities::Handler::OnSelectCursorEnd, // but better to generalize that in AudacityProject methods. selectedRegion.setT1( pCurSyl->t ); } } event.Skip(); } //v static const kHighlightTextCtrlID = 7654; BEGIN_EVENT_TABLE(LyricsPanel, wxPanelWrapper) EVT_KEY_DOWN(LyricsPanel::OnKeyEvent) EVT_PAINT(LyricsPanel::OnPaint) EVT_SIZE(LyricsPanel::OnSize) //v Doesn't seem to be a way to capture a selection event in a read-only wxTextCtrl. // EVT_COMMAND_LEFT_CLICK(kHighlightTextCtrlID, LyricsPanel::OnHighlightTextCtrl) END_EVENT_TABLE() IMPLEMENT_CLASS(LyricsPanel, wxPanel) LyricsPanel::LyricsPanel(wxWindow* parent, wxWindowID id, AudacityProject *project, const wxPoint& pos /*= wxDefaultPosition*/, const wxSize& size /*= wxDefaultSize*/) : wxPanelWrapper(parent, id, pos, size, wxWANTS_CHARS), mWidth(size.x), mHeight(size.y) , mProject(project) { mKaraokeHeight = mHeight; mLyricsStyle = kBouncingBallLyrics; // default mKaraokeFontSize = this->GetDefaultFontSize(); // Call only after mLyricsPanelStyle is set. this->SetBackgroundColour(*wxWHITE); mHighlightTextCtrl = safenew HighlightTextCtrl(this, -1, // wxWindow* parent, wxWindowID id, wxT(""), // const wxString& value = {}, wxPoint(0, 0), // const wxPoint& pos = wxDefaultPosition, size); // const wxSize& size = wxDefaultSize this->SetHighlightFont(); mHighlightTextCtrl->Show(mLyricsStyle == kHighlightLyrics); // test, in case we conditionalize the default, above mT = 0.0; Clear(); Finish(0.0); #ifdef __WXMAC__ wxSizeEvent dummyEvent; OnSize(dummyEvent); #endif parent->Bind(wxEVT_SHOW, &LyricsPanel::OnShow, this); project->Bind(EVT_UNDO_PUSHED, &LyricsPanel::UpdateLyrics, this); project->Bind(EVT_UNDO_MODIFIED, &LyricsPanel::UpdateLyrics, this); project->Bind(EVT_UNDO_OR_REDO, &LyricsPanel::UpdateLyrics, this); project->Bind(EVT_UNDO_RESET, &LyricsPanel::UpdateLyrics, this); wxTheApp->Bind(EVT_AUDIOIO_PLAYBACK, &LyricsPanel::OnStartStop, this); wxTheApp->Bind(EVT_AUDIOIO_CAPTURE, &LyricsPanel::OnStartStop, this); } LyricsPanel::~LyricsPanel() { } #define I_FIRST_REAL_SYLLABLE 2 void LyricsPanel::Clear() { mSyllables.clear(); mText = wxT(""); // Add two dummy syllables at the beginning mSyllables.push_back(Syllable()); mSyllables[0].t = -2.0; mSyllables.push_back(Syllable()); mSyllables[1].t = -1.0; mHighlightTextCtrl->Clear(); } void LyricsPanel::AddLabels(const LabelTrack *pLT) { const size_t numLabels = pLT->GetNumLabels(); wxString highlightText; for (size_t ii = 0; ii < numLabels; ++ii) { const LabelStruct *const pLabel = pLT->GetLabel(ii); Add(pLabel->getT0(), pLabel->title, highlightText); } mHighlightTextCtrl->AppendText(highlightText); } void LyricsPanel::Add(double t, const wxString &syllable, wxString &highlightText) { int i = mSyllables.size(); { Syllable &prevSyllable = mSyllables[i - 1]; if (prevSyllable.t == t) { // We can't have two syllables with the same time, so append // this to the end of the previous one if they're at the // same time. prevSyllable.text += syllable; prevSyllable.textWithSpace += syllable; prevSyllable.char1 += syllable.length(); return; } } mSyllables.push_back(Syllable()); Syllable &thisSyllable = mSyllables[i]; thisSyllable.t = t; thisSyllable.text = syllable; thisSyllable.char0 = mText.length(); // Put a space between syllables unless the previous one // ended in a hyphen if (i > 0 && // mSyllables[i-1].text.length() > 0 && mSyllables[i - 1].text.Right(1) != wxT("-")) thisSyllable.textWithSpace = wxT(" ") + syllable; else thisSyllable.textWithSpace = syllable; mText += thisSyllable.textWithSpace; thisSyllable.char1 = mText.length(); int nTextLen = thisSyllable.textWithSpace.length(); if ((nTextLen > 0) && (thisSyllable.textWithSpace.Right(1) == wxT("_"))) highlightText += (thisSyllable.textWithSpace.Left(nTextLen - 1) + wxT("\n")); else highlightText += thisSyllable.textWithSpace; } void LyricsPanel::Finish(double finalT) { // Add 3 dummy syllables at the end int i = mSyllables.size(); mSyllables.push_back(Syllable()); mSyllables[i].t = finalT + 1.0; mSyllables.push_back(Syllable()); mSyllables[i+1].t = finalT + 2.0; mSyllables.push_back(Syllable()); mSyllables[i+2].t = finalT + 3.0; // Mark measurements as invalid mMeasurementsDone = false; // only for drawn text mCurrentSyllable = 0; mHighlightTextCtrl->ShowPosition(0); } // Binary-search for the syllable whose char0 <= startChar <= char1. int LyricsPanel::FindSyllable(long startChar) { int i1, i2; i1 = 0; i2 = mSyllables.size(); while (i2 > i1+1) { int pmid = (i1+i2)/2; if (mSyllables[pmid].char0 > startChar) i2 = pmid; else i1 = pmid; } if (i1 < 2) i1 = 2; if (i1 > (int)(mSyllables.size()) - 3) i1 = mSyllables.size() - 3; return i1; } void LyricsPanel::SetLyricsStyle(const LyricsStyle newLyricsStyle) { if (mLyricsStyle == newLyricsStyle) return; mLyricsStyle = newLyricsStyle; mHighlightTextCtrl->Show(mLyricsStyle == kHighlightLyrics); wxSizeEvent ignore; this->OnSize(ignore); } unsigned int LyricsPanel::GetDefaultFontSize() const { return (mLyricsStyle == kBouncingBallLyrics) ? 48 : 10; } void LyricsPanel::SetDrawnFont(wxDC *dc) { dc->SetFont(wxFont(mKaraokeFontSize, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); } void LyricsPanel::SetHighlightFont() // for kHighlightLyrics { wxFont newFont(mKaraokeFontSize, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL); mHighlightTextCtrl->SetDefaultStyle(wxTextAttr(wxNullColour, wxNullColour, newFont)); mHighlightTextCtrl->SetStyle(0, mHighlightTextCtrl->GetLastPosition(), wxTextAttr(wxNullColour, wxNullColour, newFont)); } void LyricsPanel::Measure(wxDC *dc) // only for drawn text { this->SetDrawnFont(dc); int width = 0, height = 0; const int kIndent = 4; int x = 2*kIndent; unsigned int i; for(i = 0; i < mSyllables.size(); i++) { if ((i < I_FIRST_REAL_SYLLABLE) || // Clear() starts the list with I_FIRST_REAL_SYLLABLE dummies. (i >= mSyllables.size() - 3)) // Finish() ends with 3 dummies. { dc->GetTextExtent(wxT("DUMMY"), &width, &height); // Get the correct height even if we're at i=0. width = 0; } else { dc->GetTextExtent(mSyllables[i].textWithSpace, &width, &height); } // Add some space between words; the space is normally small but // when there's a long pause relative to the previous word, insert // extra space. int extraWidth; if (i >= I_FIRST_REAL_SYLLABLE && i < mSyllables.size() - 2) { double deltaThis = mSyllables[i+1].t - mSyllables[i].t; double deltaPrev = mSyllables[i].t - mSyllables[i-1].t; double ratio; if (deltaPrev > 0.0) ratio = deltaThis / deltaPrev; else ratio = deltaThis; if (ratio > 2.0) extraWidth = 15 + (int)(15.0 * ratio); else extraWidth = 15; } else extraWidth = 20; mSyllables[i].width = width + extraWidth; mSyllables[i].leftX = x; mSyllables[i].x = x + width/2; x += mSyllables[i].width; } mTextHeight = height; mMeasurementsDone = true; } // Binary-search for the syllable with the largest time not greater than t int LyricsPanel::FindSyllable(double t) { int i1, i2; i1 = 0; i2 = mSyllables.size(); while (i2 > i1+1) { int pmid = (i1+i2)/2; if (mSyllables[pmid].t > t) i2 = pmid; else i1 = pmid; } if (i1 < 2) i1 = 2; if (i1 > (int)(mSyllables.size()) - 3) i1 = mSyllables.size() - 3; return i1; } // Bouncing Ball: // Given the current time t, returns the x/y position of the scrolling // karaoke display. For some syllable i, when t==mSyllables[i].t, // it will return mSyllables[i].x for outX and 0 for outY. // In-between words, outX is interpolated using smooth acceleration // between the two neighboring words, and y is a positive number indicating // the bouncing ball height void LyricsPanel::GetKaraokePosition(double t, int *outX, double *outY) { *outX = 0; *outY = 0; if (t < mSyllables[I_FIRST_REAL_SYLLABLE].t || t > mSyllables[mSyllables.size() - 3].t) return; int i0, i1, i2, i3; int x0, x1, x2, x3; double t0, t1, t2, t3; i1 = FindSyllable(t); i2 = i1 + 1; // Because we've padded the syllables with two dummies at the beginning // and end, we know that i0...i3 will always exist. Also, we've made // sure that we don't ever have two of the same time, so t2>t1 strictly. // // t // \/ // time: t0 t1 t2 t3 // pos: x0 x1 x2 x3 // index: i0 i1 i2 i3 // vel: vel1 vel2 i0 = i1 - 1; i3 = i2 + 1; x0 = mSyllables[i0].x; x1 = mSyllables[i1].x; x2 = mSyllables[i2].x; x3 = mSyllables[i3].x; t0 = mSyllables[i0].t; t1 = mSyllables[i1].t; t2 = mSyllables[i2].t; t3 = mSyllables[i3].t; double linear_vel0 = (x1 - x0) / (t1 - t0); double linear_vel1 = (x2 - x1) / (t2 - t1); double linear_vel2 = (x3 - x2) / (t3 - t2); // average velocities double v1 = (linear_vel0 + linear_vel1) / 2; double v2 = (linear_vel1 + linear_vel2) / 2; // Solve a cubic equation f(t) = at^3 + bt^2 + ct + d // which gives the position x as a function of // (t - t1), by constraining f(0), f'(0), f(t2-t1), f'(t2-t1) double delta_t = t2 - t1; double delta_x = x2 - x1; v1 *= delta_t; v2 *= delta_t; double a = v1 + v2 - 2*delta_x; double b = 3*delta_x - 2*v1 - v2; double c = v1; double d = x1; t = (t - t1) / (t2 - t1); double xx = a*t*t*t + b*t*t + c*t + d; // Unfortunately sometimes our cubic goes backwards. This is a quick // hack to stop that from happening. if (xx < x1) xx = x1; *outX = (int)xx; // The y position is a simple cosine curve; the max height is a // function of the time. double height = t2 - t1 > 4.0? 1.0: sqrt((t2-t1)/4.0); *outY = height * sin(M_PI * t); } void LyricsPanel::Update(double t) { if (t < 0.0) { // TrackPanel::OnTimer passes gAudioIO->GetStreamTime(), which is -DBL_MAX if !IsStreamActive(). // In that case, use the selection start time. auto pProj = FindProjectFromWindow( this ); const auto &selectedRegion = ViewInfo::Get( *pProj ).selectedRegion; mT = selectedRegion.t0(); } else mT = t; if (mLyricsStyle == kBouncingBallLyrics) { wxRect karaokeRect(0, 0, mWidth, mKaraokeHeight); this->Refresh(false, &karaokeRect); } int i = FindSyllable(mT); if (i == mCurrentSyllable) return; mCurrentSyllable = i; if (mLyricsStyle == kHighlightLyrics) { mHighlightTextCtrl->SetSelection(mSyllables[i].char0, mSyllables[i].char1); //v No trail for now. //// Leave a trail behind the selection, by highlighting. //if (i == I_FIRST_REAL_SYLLABLE) // // Reset the trail to zero. // mHighlightTextCtrl->SetStyle(0, mHighlightTextCtrl->GetLastPosition(), wxTextAttr(wxNullColour, *wxWHITE)); //// Mark the trail for mSyllables[i]. //mHighlightTextCtrl->SetStyle(mSyllables[i].char0, mSyllables[i].char1, wxTextAttr(wxNullColour, *wxLIGHT_GREY)); //v Too much flicker: mHighlightTextCtrl->ShowPosition(mSyllables[i].char0); } } void LyricsPanel::UpdateLyrics(wxEvent &e) { e.Skip(); // It's crucial to not do that repopulating during playback. auto gAudioIO = AudioIOBase::Get(); if (gAudioIO->IsStreamActive()) { mDelayedUpdate = true; return; } Clear(); if (!mProject) return; // Lyrics come from only the first label track. auto pLabelTrack = *TrackList::Get( *mProject ).Any< const LabelTrack >().begin(); if (!pLabelTrack) return; // The code that updates the lyrics is rather expensive when there // are a lot of labels. // So - bail out early if the lyrics window is not visible. // We will later force an update when the lyrics window is made visible. auto parent = dynamic_cast(GetParent()); if( !(parent && parent->IsVisible()) ) return; AddLabels(pLabelTrack); Finish(pLabelTrack->GetEndTime()); const auto &selectedRegion = ViewInfo::Get( *mProject ).selectedRegion; Update(selectedRegion.t0()); } void LyricsPanel::OnStartStop(wxCommandEvent &e) { e.Skip(); if ( !e.GetInt() && mDelayedUpdate ) { mDelayedUpdate = false; UpdateLyrics( e ); } } void LyricsPanel::OnShow(wxShowEvent &e) { e.Skip(); if (e.IsShown()) UpdateLyrics(e); } void LyricsPanel::OnKeyEvent(wxKeyEvent & event) { auto project = FindProjectFromWindow( this ); auto &commandManager = CommandManager::Get( *project ); commandManager.FilterKeyEvent(project, event, true); event.Skip(); } void LyricsPanel::OnPaint(wxPaintEvent & WXUNUSED(event)) { wxPaintDC dc(this); DoPaint(dc); } void LyricsPanel::DoPaint(wxDC &dc) { if (!this->GetParent()->IsShown()) return; if (mLyricsStyle == kBouncingBallLyrics) { if (!mMeasurementsDone) Measure(&dc); #ifdef __WXMAC__ // Mac OS X automatically double-buffers the screen for you, // so our bitmap is unnecessary HandlePaint(dc); #else wxBitmap bitmap(mWidth, mKaraokeHeight); wxMemoryDC memDC; memDC.SelectObject(bitmap); HandlePaint(memDC); dc.Blit(0, 0, mWidth, mKaraokeHeight, &memDC, 0, 0, wxCOPY, FALSE); #endif } else // (mLyricsStyle == kHighlightLyrics) { //v causes flicker in ported version // this->SetHighlightFont(); } } void LyricsPanel::OnSize(wxSizeEvent & WXUNUSED(event)) { GetClientSize(&mWidth, &mHeight); mKaraokeHeight = mHeight; mKaraokeFontSize = (int)((float)(this->GetDefaultFontSize() * mHeight) / (float)LYRICS_DEFAULT_HEIGHT); // Usually don't get the size window we want, usually less than // LYRICS_DEFAULT_HEIGHT, so bump it a little. mKaraokeFontSize += 2; if (mLyricsStyle == kBouncingBallLyrics) { mMeasurementsDone = false; wxClientDC dc(this); this->DoPaint(dc); } else // (mLyricsStyle == kHighlightLyrics) { mHighlightTextCtrl->SetSize(mWidth, mKaraokeHeight); this->SetHighlightFont(); } this->Refresh(false); } //v Doesn't seem to be a way to capture a selection event in a read-only wxTextCtrl. //void LyricsPanel::OnHighlightTextCtrl(wxCommandEvent & event) //{ // long from, to; // // mHighlightTextCtrl->GetSelection(&from, &to); // // TODO: Find the start time of the corresponding syllable and set playback to start there. //} void LyricsPanel::HandlePaint(wxDC &dc) { wxASSERT(mLyricsStyle == kBouncingBallLyrics); dc.SetBrush(*wxWHITE_BRUSH); dc.DrawRectangle(0, 0, mWidth, mKaraokeHeight); this->HandlePaint_BouncingBall(dc); } void LyricsPanel::HandlePaint_BouncingBall(wxDC &dc) { int ctr = mWidth / 2; int x; double y; GetKaraokePosition(mT, &x, &y); dc.SetTextForeground(wxColour(238, 0, 102)); bool changedColor = false; SetDrawnFont(&dc); unsigned int i; wxCoord yTextTop = mKaraokeHeight - mTextHeight - 4; for(i = 0; i < mSyllables.size(); i++) { if (mSyllables[i].x + mSyllables[i].width < (x - ctr)) continue; if (mSyllables[i].x > x + ctr) continue; if (!changedColor && mSyllables[i].x >= x) { dc.SetTextForeground(*wxBLACK); changedColor = true; } wxString text = mSyllables[i].text; if (text.length() > 0 && text.Right(1) == wxT("_")) { text = text.Left(text.length() - 1); } dc.DrawText(text, mSyllables[i].leftX + ctr - x, yTextTop); } int ballRadius = (int)(mTextHeight / 8.0); int bounceTop = ballRadius * 2; int bounceHeight = yTextTop - bounceTop; int yi = (int)(yTextTop - 4 - (y * bounceHeight)); if (mT >= 0.0) { wxRect ball(ctr - ballRadius, yi - ballRadius, 2 * ballRadius, 2 * ballRadius); dc.SetBrush(wxBrush(wxColour(238, 0, 102), wxBRUSHSTYLE_SOLID)); dc.DrawEllipse(ball); } }