feat(train): music by cadence in a recording, or the metronome (ADR 0030, D48)

A workout keeps its cadence as a series of its own (steps a minute, every
10 s while moving). The Record sheet offers Music, Metronome or Off on
foot, remembered; Music once a kept track has a tempo. The recorder
measures the cadence over the last 30 s and plays a fitting track (its
tempo, half or double, within 6 %) through Media3's ExoPlayer, retuning
the track playing where it still fits, else crossfading over 3 s; a
plan's new step picks anew. A run starts at the cadence learnt from the
person's runs, which also gives a plan's steps their cadence.

Media3 1.10.1, pinned by Gradle's checksums (trusted on first download
from Google's Maven, 29 Oct 2026), for review:
  media3-common     aar 39686f54ff6baf8cbc841199afd55619533eaef2ab6cd0efc32b8a3c55dd88c3
                    module a81b2f0ebc256fe0e09f05d93c5259ade5bf44707748b9295343bee67575d9c2
  media3-container  aar a198b62c577fa434941282fbfa31eb66ff0ff308a6573edf0b5dd01f4e38445d
                    module e2a1c62c7e5d1013b906bc6ff5056db3e797cda4505e2d7d229524b24ca67a0e
  media3-database   aar 520e6f814e42a7d428db187ac073b453c0fcba0f4f5f4edd1cf33fafd2cfb740
                    module c380fea161e80d8074215a20e86e3dc2c2d825de964ca87aee66c8331f5ae252
  media3-datasource aar e22f9f779047ce951152c793af094593e7cbb52475a82d56fa4f869fd319e700
                    module 9d32d2ec6a983ee9f418bcc05777aa23eee4db37bf794f44fb66a05d6f22e963
  media3-decoder    aar a9fb2b8fa1d54b4a8d6bda98a1c655b04d59c74b3ace7e2503e9e39830f13aee
                    module 388d5226366edd1facf8eec42cb5420871a46032d545286e90eaea8454789580
  media3-exoplayer  aar 19090f829afbbb595d67618f59c212f72b433ac01dc72b47594cf42552c8ed61
                    module ba0b9ef325fa114baa0f2fbdb4caf54029606924be1999f4af02bed5f079756b
  media3-extractor  aar 3c823279356b64f4215a8c6cb1e6bfa60c533351a651786ad6fa69ad001ef1b1
                    module b3eb755d7373e4fd199ff80814005f3b303693dfb8ace16b5b9482655d49d493

Not tried on a phone yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 13:31:41 +01:00
commit d5b367e930
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 1435 additions and 40 deletions

View file

@ -501,5 +501,9 @@
"musicKeptDone": "{count} Titel, {size} MB, {tempo} mit Tempo",
"musicKeptDelete": "Löschen",
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie."
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.",
"recordMusic": "Musik",
"recordMusicOn": "Musik",
"recordMetronome": "Metronom",
"recordMusicOff": "Aus"
}

View file

@ -631,5 +631,10 @@
"@musicKeptDone": {"placeholders": {"count": {"type": "int"}, "size": {"type": "String", "example": "230.4"}, "tempo": {"type": "int"}}},
"musicKeptDelete": "Delete",
"musicKeptDeleteTitle": "Delete the music on this phone?",
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server."
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.",
"recordMusic": "Music",
"recordMusicOn": "Music",
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
"recordMetronome": "Metronome",
"recordMusicOff": "Off"
}

View file

@ -3033,6 +3033,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
String get musicKeptDeleteText;
/// No description provided for @recordMusic.
///
/// In en, this message translates to:
/// **'Music'**
String get recordMusic;
/// The segment playing the kept tracks by cadence.
///
/// In en, this message translates to:
/// **'Music'**
String get recordMusicOn;
/// No description provided for @recordMetronome.
///
/// In en, this message translates to:
/// **'Metronome'**
String get recordMetronome;
/// No description provided for @recordMusicOff.
///
/// In en, this message translates to:
/// **'Off'**
String get recordMusicOff;
}
class _AppLocalizationsDelegate

View file

@ -1809,4 +1809,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get musicKeptDeleteText =>
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
@override
String get recordMusic => 'Musik';
@override
String get recordMusicOn => 'Musik';
@override
String get recordMetronome => 'Metronom';
@override
String get recordMusicOff => 'Aus';
}

View file

@ -1797,6 +1797,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get musicKeptDeleteText =>
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
@override
String get recordMusic => 'Music';
@override
String get recordMusicOn => 'Music';
@override
String get recordMetronome => 'Metronome';
@override
String get recordMusicOff => 'Off';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -7,6 +7,9 @@ import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
@ -15,6 +18,7 @@ import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -147,7 +151,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
),
loop: () => train.loop?.status.loop?.id,
// Spoken in the app's language, zones from the person's profile.
planOf: (plan) async {
planOf: (plan, {cadence}) async {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final profile = await train.profile.read();
@ -156,8 +160,28 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
locale,
plan,
maxHeartRate: planMaxHeartRate(profile, clock.now()),
cadence: cadence,
);
},
// The kept tracks with a tempo, the cadence learnt from the
// person's runs (D47, D48).
musicOf: (choice, activity) async {
final kept = train.keptMusic;
if (choice == MusicChoice.off || !playsMusic(activity)) {
return null;
}
final cadence = activity == Activity.running
? await cadenceModelFrom(train.workouts)
: null;
final music = recorderMusicOf(
choice,
activity,
kept: kept?.kept.value ?? const KeptMusic(),
pathOf: (id) => kept?.fileOf(id)?.path,
cadence: cadence,
);
return music == null ? null : (music: music, cadence: cadence);
},
routeOf: (route, activity) => recorderRouteOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toLanguageTag(),

View file

@ -0,0 +1,158 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// How many runs with a cadence it takes before Train trusts what they
/// show (ADR 0030, D47).
const minCadenceRuns = 3;
/// How many cadence samples a run needs to count among them: a few
/// minutes' worth.
const minCadenceSamples = 12;
/// How a person's cadence follows their speed on foot (D47, D48): steps a
/// minute = [intercept] + [slope] × metres a second, a line fitted over
/// their past runs; the music follows it for a step that aims at a pace or
/// a speed.
final class const CadenceModel({
required final double intercept,
required final double slope,
/// The cadence the runs had on average: the music's start, before a
/// step or the steps say another.
required final int usual,
}) with Equatable {
/// The cadence at [metresPerSecond], within what running has.
int at(double metresPerSecond) =>
(intercept + slope * metresPerSecond).round().clamp(120, 220);
/// The cadence a step aiming at [target] asks for, at the middle of its
/// range: a pace (seconds a km) or a speed (km/h); null for any other.
int? forTarget(Target target) {
final middle = (target.low + target.high) / 2;
return switch (target.kind) {
TargetKind.pace when middle > 0 => at(1000 / middle),
TargetKind.speed when middle > 0 => at(middle / 3.6),
_ => null,
};
}
@override
List<Object?> get props => [intercept, slope, usual];
}
/// The cadence [runs] show against their speed: each cadence sample with
/// the speed over its 10 s through the fixes precise enough; null until
/// [minCadenceRuns] runs have [minCadenceSamples] such samples each.
/// Speeds that hardly vary give the person's usual cadence at any speed.
CadenceModel? cadenceModelOf(
Iterable<(WorkoutContent, List<SeriesChunk>)> runs,
) {
final speeds = <double>[];
final cadences = <double>[];
var counted = 0;
for (final (content, series) in runs) {
final pairs = _pairsOf(recordingOf(content, series), series);
if (pairs.length < minCadenceSamples) {
continue;
}
counted++;
for (final (speed, cadence) in pairs) {
speeds.add(speed);
cadences.add(cadence);
}
}
if (counted < minCadenceRuns) {
return null;
}
final n = speeds.length;
final meanSpeed = speeds.reduce((a, b) => a + b) / n;
final meanCadence = cadences.reduce((a, b) => a + b) / n;
var spread = 0.0;
var together = 0.0;
for (var i = 0; i < n; i++) {
spread += (speeds[i] - meanSpeed) * (speeds[i] - meanSpeed);
together += (speeds[i] - meanSpeed) * (cadences[i] - meanCadence);
}
// Under 0.3 m/s between its runs' speeds, a slope is noise; and none is
// steeper than what runners show (some 8 to 15 steps a minute per m/s).
final slope = spread / n < 0.09 ? 0.0 : (together / spread).clamp(0.0, 25.0);
return CadenceModel(
intercept: meanCadence - slope * meanSpeed,
slope: slope,
usual: meanCadence.round(),
);
}
/// How many of the latest runs [cadenceModelFrom] reads.
const cadenceRunsRead = 10;
/// The cadence model of the person's latest [cadenceRunsRead] runs Train
/// recorded (D47).
Future<CadenceModel?> cadenceModelFrom(WorkoutRepository workouts) async {
final runs = [
for (final workout in await workouts.all())
if (workout.content case final content?
when content.activity == Activity.running && content.origin == null)
(workout.id, content),
]..sort((a, b) => b.$2.startedAt.compareTo(a.$2.startedAt));
return cadenceModelOf([
for (final (id, content) in runs.take(cadenceRunsRead))
(content, await workouts.series(id)),
]);
}
/// [recording]'s speed in metres a second beside each cadence sample of
/// [series], where its fixes say one: running cadences and speeds only.
List<(double, double)> _pairsOf(
Recording recording,
List<SeriesChunk> series,
) {
final fixes = [
for (final fix in recording.fixes)
if (fix.accuracyCm <= maxAccuracyCm) fix,
];
final samples = [
for (final chunk in series)
if (chunk.kind == SeriesKind.cadence)
for (final (i, time) in chunk.absoluteTimes.indexed)
if (chunk.columns['spm']?[i] case final spm?
when spm >= 120 && spm <= 230)
(time: time, spm: spm),
]..sort((a, b) => a.time.compareTo(b.time));
final pairs = <(double, double)>[];
var first = 0;
for (final (:time, :spm) in samples) {
final from = time.subtract(cadenceWindow);
while (first < fixes.length && fixes[first].time.isBefore(from)) {
first++;
}
var metres = 0.0;
Fix? start;
Fix? last;
for (var j = first; j < fixes.length && !fixes[j].time.isAfter(time); j++) {
if (last != null) {
metres += metresBetween(last, fixes[j]);
}
start ??= fixes[j];
last = fixes[j];
}
if (start == null || last == null) {
continue;
}
final seconds = last.time.difference(start.time).inMilliseconds / 1000;
if (seconds < cadenceWindow.inSeconds / 2) {
continue;
}
final speed = metres / seconds;
if (speed >= 1.5 && speed <= 7) {
pairs.add((speed, spm.toDouble()));
}
}
return pairs;
}

View file

@ -109,6 +109,10 @@ class MusicLibrary(
_set(settings.value.copyWith(playlists: playlists));
}
/// Remembers what the Record sheet plays, by its name (D48).
void playInRecording(String choice) =>
_set(settings.value.copyWith(inRecording: choice));
/// Unmarks every playlist, which deletes their music on this phone.
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
@ -134,6 +138,7 @@ class MusicLibrary(
_set(
MusicSettings(
deviceId: settings.value.deviceId,
inRecording: settings.value.inRecording,
address: server.address,
serverName: server.name,
serverId: server.id,

View file

@ -23,15 +23,23 @@ final class const MusicSettings({
/// The playlists marked for running: their names by their IDs.
final Map<String, String> playlists = const {},
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
/// name; null before.
final String? inRecording,
}) with Equatable {
/// Whether someone is signed in (their token in the keyring).
bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it.
MusicSettings signedOut() => MusicSettings(deviceId: deviceId);
MusicSettings signedOut() =>
MusicSettings(deviceId: deviceId, inRecording: inRecording);
MusicSettings copyWith({Map<String, String>? playlists}) => MusicSettings(
MusicSettings copyWith({
Map<String, String>? playlists,
String? inRecording,
}) => MusicSettings(
deviceId: deviceId,
address: address,
serverName: serverName,
@ -39,6 +47,7 @@ final class const MusicSettings({
userId: userId,
userName: userName,
playlists: playlists ?? this.playlists,
inRecording: inRecording ?? this.inRecording,
);
@override
@ -50,6 +59,7 @@ final class const MusicSettings({
userId,
userName,
playlists,
inRecording,
];
}
@ -78,6 +88,7 @@ class MusicStore(final File _file) {
...(json['playlists'] as Map<String, Object?>? ?? const {})
.cast<String, String>(),
},
inRecording: json['inRecording'] as String?,
);
}
} on Object catch (error) {
@ -100,6 +111,7 @@ class MusicStore(final File _file) {
'userId': settings.userId,
'userName': settings.userName,
'playlists': settings.playlists,
'inRecording': settings.inRecording,
}),
);
}

View file

@ -0,0 +1,65 @@
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a recording plays (ADR 0030, D48), chosen on the Record sheet.
enum MusicChoice() {
/// The kept tracks, chosen by cadence (D47).
music,
/// A click at the cadence (D4).
metronome,
off,
}
/// Whether [activity] is on foot, where cadence is steps and music plays.
bool playsMusic(Activity activity) =>
activity == Activity.running ||
activity == Activity.walking ||
activity == Activity.hiking;
/// The cadence music starts at on foot before a model or the steps say
/// another: a run's, a walk's.
int usualCadenceOf(Activity activity) =>
activity == Activity.running ? 165 : 110;
/// What a recording plays, as the recorder takes it (D47, D48), with the
/// cadence model a plan's steps are given cadences by.
typedef RecorderMusic = ({Map<String, Object?> music, CadenceModel? cadence});
/// [choice] for [activity] as the recorder plays it: `tracks`, each kept
/// track with a tempo by its `path` and `bpm` (none for the metronome);
/// `cadence`, where the music starts, the runs' usual one where [cadence]
/// knows it. Null where nothing plays: off, not on foot, or music without a
/// track with a tempo.
Map<String, Object?>? recorderMusicOf(
MusicChoice choice,
Activity activity, {
required KeptMusic kept,
required String? Function(String id) pathOf,
CadenceModel? cadence,
}) {
if (choice == MusicChoice.off || !playsMusic(activity)) {
return null;
}
final tracks = [
if (choice == MusicChoice.music)
for (final track in kept.tracks.values)
if ((track.downloaded, track.tempo, pathOf(track.id)) case (
true,
final bpm?,
final path?,
))
{'path': path, 'bpm': bpm},
];
if (choice == MusicChoice.music && tracks.isEmpty) {
return null;
}
return {
'tracks': tracks,
'cadence': activity == Activity.running && cadence != null
? cadence.usual
: usualCadenceOf(activity),
};
}

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
@ -23,13 +24,16 @@ int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
/// [locale] (a BCP 47 tag): `plan`, its payload and [maxHeartRate], which
/// the recording file keeps; `steps`, the run spelled out, each with its end (`seconds` or
/// `metres`, neither for a lap tap), its target, zones as the heart rates
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
/// words for off target and for the end.
/// they span for [maxHeartRate], what is said as it starts and, where
/// [cadence] knows the person's runs, the cadence a pace's or a speed's
/// step asks of the music (D47); `say`, the words for off target and for
/// the end.
Map<String, Object?> recorderPlanOf(
AppLocalizations l10n,
String locale,
PlanContent plan, {
required int maxHeartRate,
CadenceModel? cadence,
}) => {
'plan': {
...jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan))))
@ -64,6 +68,8 @@ Map<String, Object?> recorderPlanOf(
},
},
'say': spokenStep(l10n, locale, run),
if ((cadence, run.step.target) case (final model?, final target?))
'cadence': ?model.forTarget(target),
},
],
'say': {

View file

@ -5,6 +5,8 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
@ -53,7 +55,18 @@ class RecordingBloc(
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
/// language and with the person's heart rates.
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
/// With [musicOf]'s cadence model, a pace's or a speed's step asks the
/// music for a cadence (D47).
final Future<Map<String, Object?>> Function(
PlanContent plan, {
CadenceModel? cadence,
})?
planOf,
/// What a recording of an activity plays, as the recorder takes it
/// (`recorderMusicOf`, D48); null where it plays nothing.
final Future<RecorderMusic?> Function(MusicChoice choice, Activity activity)?
musicOf,
/// A route as the recorder takes it (`recorderRouteOf`) for the activity
/// recorded, in the app's language.
@ -178,7 +191,10 @@ class RecordingBloc(
if (rows) RecorderPermission.bluetooth,
if (gps) RecorderPermission.location,
RecorderPermission.notifications,
if (autoPause == AutoPauseBy.steps) RecorderPermission.activity,
// Steps on foot whatever pauses it: they are the workout's cadence
// and what its music follows (D48).
if (autoPause == AutoPauseBy.steps || playsMusic(activity))
RecorderPermission.activity,
});
if (gps && !granted.contains(RecorderPermission.location)) {
emit(state.copyWith(locationRefused: true));
@ -188,8 +204,17 @@ class RecordingBloc(
emit(state.copyWith(bluetoothRefused: true));
return;
}
final music = switch (musicOf) {
final musicOf? => await musicOf(event.music, activity),
null => null,
};
final plan = switch ((event.plan, planOf)) {
(final plan?, final planOf?) => await planOf(plan),
(final plan?, final planOf?) => await planOf(
plan,
cadence: music == null || activity != Activity.running
? null
: music.cadence,
),
_ => null,
};
final route = switch ((event.route, routeOf)) {
@ -211,6 +236,7 @@ class RecordingBloc(
loop: loop?.call(),
plan: plan,
route: route,
music: music?.music,
);
_shown = _keyOf(notice);
_follow();
@ -470,13 +496,14 @@ final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
/// [plan] where one was chosen (D42), following [route] where one was
/// (D45).
/// (D45), playing [music] (D48).
final class const RecordingEventStartRequested(
final Activity activity, {
final bool autoPause = true,
final bool useStrap = true,
final PlanContent? plan,
final RouteContent? route,
final MusicChoice music = MusicChoice.off,
}) extends RecordingEvent;
/// The person chose [device] as their [kind] on this device.

View file

@ -39,6 +39,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
String? loop,
Map<String, Object?>? plan,
Map<String, Object?>? route,
Map<String, Object?>? music,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
@ -49,6 +50,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'loop': loop,
'plan': plan == null ? null : jsonEncode(plan),
'route': route == null ? null : jsonEncode(route),
'music': music == null ? null : jsonEncode(music),
});
@override

View file

@ -265,6 +265,16 @@ List<SeriesChunk> seriesOf(Recording recording) {
'watts': [for (final sample in rowing) sample.watts],
},
),
...switch (cadenceOf(recording)) {
final cadence => chunksOf(
SeriesKind.cadence,
start: start,
times: times(cadence.map((sample) => sample.time)),
columns: {
'spm': [for (final sample in cadence) sample.spm],
},
),
},
...chunksOf(
SeriesKind.pauses,
start: start,
@ -276,3 +286,57 @@ List<SeriesChunk> seriesOf(Recording recording) {
),
];
}
/// How often [cadenceOf] samples the steps.
const cadenceWindow = Duration(seconds: 10);
/// [recording]'s steps as a cadence (D48): from its start, each
/// [cadenceWindow] that ends at or before its last step and isn't paused
/// at either end, the steps in it a minute, timed at its end.
List<({DateTime time, int spm})> cadenceOf(Recording recording) {
final steps = recording.steps;
if (steps.isEmpty) {
return const [];
}
final pauses = recording.pauses;
bool pausedAt(DateTime time) {
var paused = false;
for (final mark in pauses) {
if (mark.time.isAfter(time)) {
break;
}
paused = mark.paused;
}
return paused;
}
final perMinute =
const Duration(minutes: 1).inMilliseconds / cadenceWindow.inMilliseconds;
final samples = <({DateTime time, int spm})>[];
var next = 0;
for (
var from = recording.startedAt;
!from.add(cadenceWindow).isAfter(steps.last.add(cadenceWindow ~/ 2));
from = from.add(cadenceWindow)
) {
final to = from.add(cadenceWindow);
var count = 0;
while (next < steps.length && steps[next].isBefore(to)) {
if (!steps[next].isBefore(from)) {
count++;
}
next++;
}
// A window touching a pause holds a part of a minute's steps only.
final touchesPause = pauses.any(
(mark) => mark.time.isAfter(from) && mark.time.isBefore(to),
);
if (pausedAt(from) ||
pausedAt(to.subtract(const Duration(milliseconds: 1))) ||
touchesPause) {
continue;
}
samples.add((time: to, spm: (count * perMinute).round()));
}
return samples;
}

View file

@ -110,6 +110,10 @@ abstract interface class RecorderPlatform() {
/// The route it follows, as the recorder takes it (D45,
/// `recorderRouteOf`).
Map<String, Object?>? route,
/// What it plays, as the recorder takes it (D47, D48,
/// `recorderMusicOf`).
Map<String, Object?>? music,
});
/// Pauses, or goes on, by hand.

View file

@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
@ -27,6 +29,9 @@ const recordedActivities = [
Activity.indoorRowing,
];
/// What a device without kept music keeps.
final _noMusic = ValueNotifier(const KeptMusic());
/// Whether auto-pause is on by default for [activity] (ADR 0030, D16, D28).
bool autoPausesByDefault(Activity activity) =>
activity == Activity.running ||
@ -38,7 +43,8 @@ bool autoPausesByDefault(Activity activity) =>
/// and switched off for one workout (D27), the Loop named where it gives the
/// heart rate (D41), for a row the rower, without which it doesn't start
/// (D28), the interval plan to run, none by default (D42), and the route
/// to follow, none by default, for an activity with GPS (D45); true once
/// to follow, none by default, for an activity with GPS (D45), and on foot
/// what it plays, as last chosen (D48); true once
/// Start was pressed, the start asked of [bloc].
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
await showAdaptiveSheet<bool>(
@ -69,6 +75,14 @@ class _RecordSheetState() extends State<_RecordSheet> {
/// The route it follows; none by default.
RouteContent? _route;
/// What it plays on foot (D48): as last chosen, Off at first.
late var _music = switch (PersonScope.of<TrainPerson>(
context,
).music?.settings.value.inRecording) {
final name? => MusicChoice.values.asNameMap()[name] ?? MusicChoice.off,
null => MusicChoice.off,
};
/// [activity] chosen, with its auto-pause's default.
void _choose(Activity activity) {
_activity = activity;
@ -192,6 +206,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
}
},
),
if (playsMusic(_activity)) _musicChoice(context, l10n),
],
),
footer: BlocBuilder<RecordingBloc, RecordingState>(
@ -209,8 +224,12 @@ class _RecordSheetState() extends State<_RecordSheet> {
useStrap: _useStrap,
plan: _plan,
route: _activity == Activity.indoorRowing ? null : _route,
music: playsMusic(_activity) ? _music : MusicChoice.off,
),
);
PersonScope.of<TrainPerson>(
context,
).music?.playInRecording(_music.name);
Navigator.of(context).pop(true);
},
child: Text(l10n.recordStart),
@ -219,6 +238,49 @@ class _RecordSheetState() extends State<_RecordSheet> {
);
}
/// Music, the metronome or nothing; Music only once a kept track has a
/// tempo.
Widget _musicChoice(BuildContext context, AppLocalizations l10n) {
final kept = PersonScope.of<TrainPerson>(context).keptMusic;
return ValueListenableBuilder(
valueListenable: kept?.kept ?? _noMusic,
builder: (context, music, _) {
final hasMusic = music.withTempo > 0;
final chosen = _music == MusicChoice.music && !hasMusic
? MusicChoice.off
: _music;
return ListTile(
leading: const Icon(LucideIcons.music),
title: Text(l10n.recordMusic),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: SegmentedButton<MusicChoice>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: MusicChoice.music,
label: Text(l10n.recordMusicOn),
enabled: hasMusic,
),
ButtonSegment(
value: MusicChoice.metronome,
label: Text(l10n.recordMetronome),
),
ButtonSegment(
value: MusicChoice.off,
label: Text(l10n.recordMusicOff),
),
],
selected: {chosen},
onSelectionChanged: (choice) =>
setState(() => _music = choice.single),
),
),
);
},
);
}
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
showDeviceSheet(
context,

View file

@ -474,6 +474,9 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
),
);
}
case SeriesKind.cadence:
// A recording holds its steps, which a cadence can't give back; the
// music reads it from the series (`cadenceModelOf`).
}
}
rowing.sort((a, b) => a.time.compareTo(b.time));

View file

@ -17,6 +17,10 @@ enum SeriesKind() {
/// The rower's frames (D28): `rate2` the stroke rate in half strokes a
/// minute, `strokes` and `metres` since the first frame, `watts`.
rowing,
/// The steps' cadence (D48): `spm`, the steps a minute over the 10 s
/// before each sample, taken while moving.
cadence,
}
/// How long a chunk runs at most: a 3-hour ride is 18 chunks of a few KB