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:
parent
7f18f001f4
commit
d5b367e930
5 changed files with 1435 additions and 40 deletions
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue