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
5 changed files with 1435 additions and 40 deletions

View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.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/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The cadence a person runs at a pace, learnt from their runs (ADR 0030,
/// D47, D48).
void main() {
/// A run of [minutes] at [speed] m/s with a cadence of 150 + 8 × speed,
/// saved as Train saves it.
(WorkoutContent, List<SeriesChunk>) run(
double speed, {
int minutes = 5,
bool steps = true,
}) {
final start = DateTime.utc(2026, 10, 1, 7);
final spm = 150 + 8 * speed;
final seconds = minutes * 60;
return recordedWorkout(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [
for (var s = 0; s <= seconds; s++)
Fix(
time: start.add(Duration(seconds: s)),
// 1e-7 degrees of latitude are 1.112 cm at the equator.
latE7: (s * speed * 89.932).round(),
lonE7: 0,
accuracyCm: 500,
),
],
steps: steps
? [
for (var ms = 0.0; ms < seconds * 1000; ms += 60000 / spm)
start.add(Duration(milliseconds: ms.round())),
]
: const [],
endedAt: start.add(Duration(seconds: seconds)),
),
);
}
test('three runs at different speeds give the line they ran', () {
final model = cadenceModelOf([run(2.5), run(3.2), run(4)])!;
expect(model.slope, closeTo(8, 1));
expect(model.at(3), closeTo(174, 2));
expect(model.usual, closeTo(175, 2));
});
test("a pace's or a speed's step asks for the cadence at its middle; any "
'other target for none', () {
const model = CadenceModel(intercept: 150, slope: 8, usual: 176);
// 5:00 a km is 3.33 m/s.
expect(model.forTarget(const Target(TargetKind.pace, 290, 310)), 177);
expect(model.forTarget(const Target(TargetKind.speed, 12, 12)), 177);
expect(model.forTarget(Target.zone(2)), isNull);
});
test('fewer than three runs with steps, or runs too short, give none', () {
expect(cadenceModelOf([run(3), run(3.5)]), isNull);
expect(cadenceModelOf([run(3), run(3.5), run(4, steps: false)]), isNull);
expect(cadenceModelOf([run(3), run(3.5), run(4, minutes: 1)]), isNull);
});
test('runs at one speed give their usual cadence at any speed', () {
final model = cadenceModelOf([run(3), run(3), run(3)])!;
expect(model.slope, 0);
expect(model.at(5), closeTo(174, 2));
});
}

View file

@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.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/music/model/music_track.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a recording plays, as the recorder takes it (ADR 0030, D48).
void main() {
const kept = KeptMusic(
tracks: {
't1': MusicTrack(id: 't1', name: 'One', bytes: 10, tempo: 172),
't2': MusicTrack(id: 't2', name: 'Two', bytes: 10, analysed: true),
't3': MusicTrack(id: 't3', name: 'Three', tempo: 128),
},
);
String pathOf(String id) => '/music/$id';
const model = CadenceModel(intercept: 150, slope: 8, usual: 176);
test('music: the downloaded tracks with a tempo, from the cadence the '
"person's runs had", () {
expect(
recorderMusicOf(
MusicChoice.music,
Activity.running,
kept: kept,
pathOf: pathOf,
cadence: model,
),
{
'tracks': [
{'path': '/music/t1', 'bpm': 172.0},
],
'cadence': 176,
},
);
});
test("the metronome has no tracks; a walk starts at a walk's cadence", () {
expect(
recorderMusicOf(
MusicChoice.metronome,
Activity.walking,
kept: kept,
pathOf: pathOf,
cadence: model,
),
{'tracks': <Object?>[], 'cadence': 110},
);
expect(
recorderMusicOf(
MusicChoice.metronome,
Activity.running,
kept: const KeptMusic(),
pathOf: pathOf,
),
{'tracks': <Object?>[], 'cadence': 165},
);
});
test('off, a ride, or music without a track with a tempo plays nothing', () {
expect(
recorderMusicOf(
MusicChoice.off,
Activity.running,
kept: kept,
pathOf: pathOf,
),
isNull,
);
expect(
recorderMusicOf(
MusicChoice.music,
Activity.cycling,
kept: kept,
pathOf: pathOf,
),
isNull,
);
expect(
recorderMusicOf(
MusicChoice.music,
Activity.running,
kept: const KeptMusic(),
pathOf: pathOf,
),
isNull,
);
});
}

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_de.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_en.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
@ -79,6 +80,24 @@ void main() {
});
});
test("with the person's cadence model, a pace's step asks the music for "
"its cadence; a zone's for none (D47)", () {
final json = recorderPlanOf(
en,
'en-GB',
plan,
maxHeartRate: 190,
cadence: const CadenceModel(intercept: 150, slope: 8, usual: 172),
);
final steps = (json['steps']! as List<Object?>)
.cast<Map<String, Object?>>();
// 5:45 a km is 2.90 m/s: 150 + 8 × 2.90.
expect(steps[2]['cadence'], 173);
expect(steps[1].containsKey('cadence'), isFalse);
expect(steps[0].containsKey('cadence'), isFalse);
});
test('the plan itself goes too, as its payload, for the recording file', () {
final json = recorderPlanOf(en, 'en-GB', plan, maxHeartRate: 190);

View file

@ -176,6 +176,45 @@ void main() {
);
});
test('its steps are its cadence, steps a minute every 10 s while it '
'moves, its pauses left out (D48)', () {
final (_, series) = recordedWorkout(
Recording(
activity: Activity.running,
startedAt: start,
// 170 a minute for 40 s, paused from 40 s to 60 s, then 150.
steps: [
for (var ms = 0; ms < 40000; ms += 353)
start.add(Duration(milliseconds: ms)),
for (var ms = 60000; ms < 80000; ms += 400)
start.add(Duration(milliseconds: ms)),
],
pauses: [
PauseMark(time: at(40)),
PauseMark(time: at(60), paused: false),
],
endedAt: at(80),
),
);
final cadence = series.singleWhere((c) => c.kind == SeriesKind.cadence);
expect(cadence.start, at(10));
expect(cadence.absoluteTimes, [
at(10),
at(20),
at(30),
at(40),
at(70),
at(80),
]);
expect(cadence.columns['spm'], [174, 168, 168, 174, 150, 150]);
});
test('a recording without steps has no cadence', () {
final (_, series) = recordedWorkout(run(endedAt: at(60)));
expect(series.where((c) => c.kind == SeriesKind.cadence), isEmpty);
});
group('a row (D28)', () {
RowingSample stroke(int seconds, int metres, {int strokes = 0}) =>
RowingSample(

View file

@ -5,6 +5,8 @@ import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.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/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
@ -60,6 +62,9 @@ void main() {
/// The person's Loop, where they have one (D41).
String? loop;
/// What a recording plays, as the app makes it (D48); none by default.
RecorderMusic? Function(MusicChoice choice, Activity activity)? music;
/// The bloc as the screen opens it.
Future<RecordingBloc> open() async {
final bloc = RecordingBloc(
@ -71,7 +76,11 @@ void main() {
notice: notice,
ticks: ticks.stream,
loop: () => loop,
planOf: (plan) async => {'name': plan.name},
planOf: (plan, {cadence}) async => {
'name': plan.name,
if (cadence != null) 'cadence': cadence.usual,
},
musicOf: (choice, activity) async => music?.call(choice, activity),
routeOf: (route, activity) => {
'name': route.name,
'activity': activity.name,
@ -137,27 +146,37 @@ void main() {
expect(platform.notices.single.title, 'running');
});
test('a ride pauses by speed and asks for no step detector; without '
'auto-pause a walk asks for none either', () async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.stopped);
bloc.add(const RecordingEventDiscardRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.idle);
test(
'a ride pauses by speed and asks for no step detector; without '
'auto-pause a walk still asks for it, its steps its cadence (D48)',
() async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.cycling));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.stopped);
bloc.add(const RecordingEventDiscardRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.idle);
bloc.add(
const RecordingEventStartRequested(Activity.walking, autoPause: false),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(
const RecordingEventStartRequested(Activity.walking, autoPause: false),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.asked, [
{RecorderPermission.location, RecorderPermission.notifications},
{RecorderPermission.location, RecorderPermission.notifications},
]);
expect(platform.started.map((s) => s.autoPause), [AutoPauseBy.bike, null]);
});
expect(platform.asked, [
{RecorderPermission.location, RecorderPermission.notifications},
{
RecorderPermission.location,
RecorderPermission.notifications,
RecorderPermission.activity,
},
]);
expect(platform.started.map((s) => s.autoPause), [
AutoPauseBy.bike,
null,
]);
},
);
test('without location a GPS activity starts not, and says why', () async {
platform.allows = {RecorderPermission.notifications};
@ -490,6 +509,78 @@ void main() {
});
});
group('music (D48)', () {
const model = CadenceModel(intercept: 150, slope: 8, usual: 172);
const plan = PlanContent(
name: 'Tempo',
blocks: [
PlanBlock(
steps: [
PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 20)),
),
],
),
],
);
setUp(() {
music = (choice, activity) => choice == MusicChoice.off
? null
: (
music: {'choice': choice.name, 'activity': activity.name},
cadence: model,
);
});
test("what was chosen goes to the recorder, and a run's plan asks its "
'steps for cadences by the model; off, nothing plays', () async {
final bloc = await open();
bloc.add(
const RecordingEventStartRequested(
Activity.running,
plan: plan,
music: MusicChoice.metronome,
),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase != RecordingPhase.recording);
bloc.add(const RecordingEventDiscardRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.idle);
bloc.add(
const RecordingEventStartRequested(Activity.running, plan: plan),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.started.first.music, {
'choice': 'metronome',
'activity': 'running',
});
expect(platform.started.first.plan, {'name': 'Tempo', 'cadence': 172});
expect(platform.started.last.music, isNull);
expect(platform.started.last.plan, {'name': 'Tempo'});
});
test('a walk plays, but its plan asks for no cadence: the model is '
"a run's", () async {
final bloc = await open();
bloc.add(
const RecordingEventStartRequested(
Activity.walking,
plan: plan,
music: MusicChoice.music,
),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.started.single.music, isNotNull);
expect(platform.started.single.plan, {'name': 'Tempo'});
});
});
group('a route (D45)', () {
const route = RouteContent(
name: 'Park',