mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-04-13 12:42:20 +00:00
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:
committed by
GitHub
parent
911b9bd966
commit
1f8bafb334
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
31
test/workouts/TRwithTextCues.mrc
Normal file
31
test/workouts/TRwithTextCues.mrc
Normal 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]
|
||||
Reference in New Issue
Block a user