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
18 changed files with 1435 additions and 40 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue