Text Cues on TrainBottom display pane (#3544)

Import Texts from Erg files in TrainerRoad format, Zwo files and
from Lap names in json files.
Display texts on TrainBottom for both, erg and slope mode, at the
corresponding time/distance for the specified duration.
Export Texts in erg, mrc and zwo formats.
Fixes #1118
Fixes #2967
Prerequisite for #2098
This commit is contained in:
Alejandro Martinez
2020-07-16 12:55:41 -03:00
committed by GitHub
parent 911b9bd966
commit 1f8bafb334
8 changed files with 162 additions and 12 deletions

View File

@@ -180,6 +180,7 @@ class RideFile : public QObject // QObject to emit signals
friend class ManualRideDialog;
friend class PolarFileReader;
friend class Strava;
friend class ErgFile; // access to intervals
// split and mergers
friend class MergeActivityWizard;
friend class SplitActivityWizard;

View File

@@ -140,6 +140,7 @@ void ErgFile::parseZwift()
format = ERG; // default to couse until we know
Points.clear();
Laps.clear();
Texts.clear();
gpi.Reset();
@@ -201,6 +202,7 @@ void ErgFile::parseErg2(QString p)
mode = format = ERG; // default to couse until we know
Points.clear();
Laps.clear();
Texts.clear();
gpi.Reset();
@@ -268,6 +270,7 @@ void ErgFile::parseTacx()
format = CRS; // default to couse until we know
Points.clear();
Laps.clear();
Texts.clear();
gpi.Reset();
@@ -452,6 +455,7 @@ void ErgFile::parseComputrainer(QString p)
int lapcounter = 0;
format = ERG; // either ERG or MRC
Points.clear();
Texts.clear();
// start by assuming the input file is Metric
bool bIsMetric = true;
@@ -470,6 +474,8 @@ void ErgFile::parseComputrainer(QString p)
QRegExp endHeader("^.*\\[END COURSE HEADER\\].*$", Qt::CaseInsensitive);
QRegExp startData("^.*\\[COURSE DATA\\].*$", Qt::CaseInsensitive);
QRegExp endData("^.*\\[END COURSE DATA\\].*$", Qt::CaseInsensitive);
QRegExp startText("^.*\\[COURSE TEXT\\].*$", Qt::CaseInsensitive);
QRegExp endText("^.*\\[END COURSE TEXT\\].*$", Qt::CaseInsensitive);
// ignore whitespace and support for ';' comments (a GC extension)
QRegExp ignore("^(;.*|[ \\t\\n]*)$", Qt::CaseInsensitive);
// workout settings
@@ -492,6 +498,9 @@ void ErgFile::parseComputrainer(QString p)
QRegExp lapmarker("^[ \\t]*([0-9\\.]+)[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive);
QRegExp crslapmarker("^[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive);
// text cue records
QRegExp textCue("^([0-9\\.]+)[ \\t]*([^\\t]+)[\\t]+([0-9]+)[ \\t\\n]*$", Qt::CaseInsensitive);
// ok. opened ok lets parse.
QTextStream inputStream(&ergFile);
QTextStream stringStream(&p);
@@ -523,6 +532,10 @@ void ErgFile::parseComputrainer(QString p)
section = DATA;
} else if (endData.exactMatch(line)) {
section = END;
} else if (startText.exactMatch(line)) {
section = TEXTS;
} else if (endText.exactMatch(line)) {
section = END;
} else if (ergformat.exactMatch(line)) {
// save away the format
mode = format = ERG;
@@ -642,6 +655,12 @@ void ErgFile::parseComputrainer(QString p)
} else if (ignore.exactMatch(line)) {
// do nothing for this line
} else if (section == TEXTS && textCue.exactMatch(line)) {
// x, text cue, duration
double x = textCue.cap(1).toDouble() * 1000.; // convert to msecs or m
int duration = textCue.cap(3).toInt(); // duration in secs
Texts<<ErgFileText(x, duration, textCue.cap(2));
} else {
// ignore bad lines for now. just bark.
//qDebug()<<"huh?" << line;
@@ -721,6 +740,7 @@ void ErgFile::parseFromRideFileFactory()
format = mode = CRS;
Points.clear();
Laps.clear();
Texts.clear();
gpi.Reset();
@@ -815,6 +835,14 @@ void ErgFile::parseFromRideFileFactory()
Points.append(add);
}
// Add interval names as text cues
foreach(const RideFileInterval* lap, ride->intervals()) {
double x = ride->timeToDistance(lap->start) * 1000.0;
int duration = lap->stop - lap->start + 1;
if (x > 0 && duration > 0 && !lap->name.isEmpty())
Texts<<ErgFileText(x, duration, lap->name);
}
gpxFile.close();
valid = true;
@@ -847,6 +875,7 @@ void ErgFile::parseTTS()
format = mode = CRS;
Points.clear();
Laps.clear();
Texts.clear();
gpi.Reset();
@@ -1114,6 +1143,17 @@ ErgFile::save(QStringList &errors)
}
out << "[END COURSE DATA]\n";
// TEXTS in TrainerRoad compatible format
if (Texts.count() > 0) {
out << "[COURSE TEXT]\n";
foreach(ErgFileText cue, Texts)
out <<QString("%1\t%2\t%3\n").arg(cue.x/1000)
.arg(cue.text)
.arg(cue.duration);
out << "[END COURSE TEXT]\n";
}
f.close();
}
@@ -1191,6 +1231,17 @@ ErgFile::save(QStringList &errors)
}
out << "[END COURSE DATA]\n";
// TEXTS in TrainerRoad compatible format
if (Texts.count() > 0) {
out << "[COURSE TEXT]\n";
foreach(ErgFileText cue, Texts)
out <<QString("%1\t%2\t%3\n").arg(cue.x/1000)
.arg(cue.text)
.arg(cue.duration);
out << "[END COURSE TEXT]\n";
}
f.close();
}
@@ -1243,6 +1294,7 @@ ErgFile::save(QStringList &errors)
out << " <workout>\n";
QList<ErgFileSection> sections = Sections();
int msecs = 0;
for(int i=0; i<sections.count(); i++) {
// are there repeated sections of efforts and recovery?
@@ -1270,8 +1322,22 @@ ErgFile::save(QStringList &errors)
<< "PowerOnLow=\"" << sections[i].start/CP << "\" "
<< "PowerOnHigh=\"" << sections[i].end/CP << "\" "
<< "PowerOffLow=\"" << sections[i+1].start/CP << "\" "
<< "PowerOffHigh=\"" << sections[i+1].end/CP << "\" />\n";
<< "PowerOffHigh=\"" << sections[i+1].end/CP << "\" ";
int d = (count+1)*(sections[i].duration+sections[i+1].duration);
if (Texts.count() > 0) {
out << ">\n";
foreach (ErgFileText cue, Texts)
if (cue.x >= msecs && cue.x <= msecs+d)
out << " <textevent "
<< "timeoffset=\""<<(cue.x-msecs)/1000
<< "\" message=\"" << cue.text
<< "\" duration=\"" << cue.duration << "\"/>\n";
out << " </IntervalsT>\n";
} else {
out << "/>\n";
}
msecs += d;
// skip on, bearing in mind the main loop increases i by 1
i += 1 + (count*2);
@@ -1286,8 +1352,20 @@ ErgFile::save(QStringList &errors)
out << " <" << tag << " Duration=\""<<sections[i].duration/1000 << "\" "
<< "PowerLow=\"" <<sections[i].start/CP << "\" "
<< "PowerHigh=\"" <<sections[i].end/CP << "\" />\n";
<< "PowerHigh=\"" <<sections[i].end/CP << "\"";
if (Texts.count() > 0) {
out << ">\n";
foreach (ErgFileText cue, Texts)
if (cue.x >= msecs && cue.x <= msecs+sections[i].duration)
out << " <textevent "
<< "timeoffset=\""<<(cue.x-msecs)/1000
<< "\" message=\"" << cue.text
<< "\" duration=\"" << cue.duration << "\"/>\n";
out << " </" << tag << ">\n";
} else {
out << "/>\n";
}
msecs += sections[i].duration;
}
}
out << " </workout>\n";
@@ -1522,6 +1600,20 @@ ErgFile::currentLap(long x)
return -1; // No matching lap
}
// Retrieve the index of next text cue.
// Params: x - current workout distance (m) / time (ms)
// Returns: index of next text cue.
int ErgFile::nextText(long x)
{
if (!isValid()) return -1; // not a valid ergfile
// If the current position is before the text, then the text is next
for (int i=0; i<Texts.count(); i++) {
if (x <= Texts.at(i).x) return i;
}
return -1; // nope, no marker ahead of there
}
void
ErgFile::calculateMetrics()
{

View File

@@ -39,6 +39,7 @@
#define SETTINGS 1
#define DATA 2
#define END 3
#define TEXTS 4
// is this in .erg or .mrc format?
#define ERG 1
@@ -75,11 +76,11 @@ class ErgFileSection
class ErgFileText
{
public:
ErgFileText() : x(0), pos(0), text("") {}
ErgFileText(double x, int pos, QString text) : x(x), pos(pos), text(text) {}
ErgFileText() : x(0), duration(0), text("") {}
ErgFileText(double x, int duration, QString text) : x(x), duration(duration), text(text) {}
double x;
int pos;
int duration;
QString text;
};
@@ -128,6 +129,8 @@ class ErgFile
int nextLap(long); // return the start value (erg - time(ms) or slope - distance(m)) for the next lap
int currentLap(long); // return the start value (erg - time(ms) or slope - distance(m)) for the current lap
int nextText(long); // return the index for the next text cue
// turn the ergfile into a series of sections rather
// than a list of points
QList<ErgFileSection> Sections();

View File

@@ -1808,6 +1808,21 @@ void TrainSidebar::guiUpdate() // refreshes the telemetry
}
}
// Text Cues
if (ergFile && ergFile->Texts.count() > 0) {
// find the next cue
double pos = status&RT_MODE_ERGO ? load_msecs : displayWorkoutDistance*1000;
int idx = ergFile->nextText(pos);
if (idx >= 0) {
ErgFileText cue = ergFile->Texts.at(idx);
// show when we are approaching it
if (((status&RT_MODE_ERGO) && cue.x<load_msecs+1000) ||
((status&RT_MODE_SLOPE) && cue.x < displayWorkoutDistance*1000 + 10)) {
emit setNotification(cue.text, cue.duration);
}
}
}
if(lapTimeRemaining < 0) {
if (ergFile) lapTimeRemaining = ergFile->Duration - load_msecs;
if (lapTimeRemaining < 0)

View File

@@ -531,7 +531,7 @@ WorkoutWindow::saveAs()
{
QString filename = QFileDialog::getSaveFileName(this, tr("Save Workout File"),
appsettings->value(this, GC_WORKOUTDIR, "").toString(),
"ERG workout (*.erg);;Zwift workout (*.zwo)");
"ERG workout (*.erg);;MRC workout (*.mrc);;Zwift workout (*.zwo)");
// if they didn't select, give up.
if (filename.isEmpty()) {
@@ -539,7 +539,7 @@ WorkoutWindow::saveAs()
}
// filetype defaults to .erg
if(!filename.endsWith(".erg") && !filename.endsWith(".zwo")) {
if(!filename.endsWith(".erg") && !filename.endsWith(".mrc") && !filename.endsWith(".zwo")) {
filename.append(".erg");
}

View File

@@ -22,6 +22,7 @@ bool ZwoParser::startDocument()
{
buffer.clear();
secs = 0;
sSecs = 0;
watts = 0;
return true;
}
@@ -52,6 +53,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
double PowerLow = attrs.value("PowerLow").toDouble();
double PowerHigh = attrs.value("PowerHigh").toDouble();
double Power = attrs.value("Power").toDouble();
bool ftpTest = attrs.value("ftptest").toInt();
// Either Power or PowerLow / PowerHigh are available
// PowerHigh may be optional and should be same as low.
@@ -66,7 +68,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
// POINTS
// basic from/to, with different names for some odd reason
if (qName == "Warmup" || qName == "SteadyState" || qName == "Cooldown" || qName == "FreeRide") {
if (qName == "Warmup" || qName == "SteadyState" || qName == "Cooldown" || qName == "FreeRide" || qName == "Freeride") {
int from = int(100.0 * PowerLow);
int to = int(100.0 * PowerHigh);
@@ -77,8 +79,9 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
from = to = ap;
}
if (qName == "FreeRide") {
if (watts == 0) from = to = 70;
if (qName == "FreeRide" || qName == "Freeride") {
if (ftpTest) from = to = 100; // if marked as FTP test, assume 100%
else if (watts == 0) from = to = 70;
else from = to = watts; // whatever we were just doing keep doing it
}
@@ -87,6 +90,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
points << ErgFilePoint(secs * 1000, from, from);
watts = from;
}
sSecs = secs;
secs += Duration;
points << ErgFilePoint(secs * 1000, to, to);
watts = to;
@@ -125,6 +129,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
sSecs = secs;
while (count--) {
// add if not already on that wattage
@@ -156,8 +161,10 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName,
} else if (qName == "textevent") {
int offset = attrs.value("timeoffset").toInt();
QString message=attrs.value("message");
int duration = attrs.value("duration").toInt();
int pos = attrs.value("y").toInt();
texts << ErgFileText((secs+offset) * 1000, pos, message);
if (pos >= 240) pos -= 240; // no position, use excess as delay
texts << ErgFileText((sSecs+offset+pos)*1000, duration, message);
}
// and clear anything left in the buffer

View File

@@ -38,6 +38,7 @@ public:
QString buffer;
int secs; // rolling secs as points read
int sSecs; // rolling starting secs as points read
int watts; // watts set at last point
// the data in it

View File

@@ -0,0 +1,31 @@
[COURSE HEADER]
VERSION = 2
UNITS = ENGLISH
DESCRIPTION = A description
FILE NAME = blah.mrc
MINUTES PERCENT
[END COURSE HEADER]
[COURSE DATA]
0.00 50
6.60 50
6.60 100
7.98 140
7.98 50
9.07 50
9.07 150
10.10 150
10.10 50
14.07 50
14.07 115
22.07 115
22.07 50
32.08 50
32.08 115
40.08 115
40.08 50
51.88 50
[END COURSE DATA]
[COURSE TEXT]
600 This is a new message 10
615 This message will show up at 10:15 into the workout 10
[END COURSE TEXT]