mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:47:41 +01:00
Pace target in backup, run rule comments
Back up and restore the pace target (null or absent = off, a stale target is cleared). Mirror comments in run_math and friends now state the PC's merged rule and where the app is stricter. Pace tones get MANIFEST entries; default map and backup get tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
This commit is contained in:
parent
cd32c0a79e
commit
9641906818
@ -1,4 +1,48 @@
|
||||
{
|
||||
"pace_fast.wav": {
|
||||
"file": "pace_fast.wav",
|
||||
"frames": 12348,
|
||||
"recipe": {
|
||||
"attack_seconds": 0.01,
|
||||
"notes": [
|
||||
[
|
||||
880.0,
|
||||
0.11
|
||||
],
|
||||
[
|
||||
1319.0,
|
||||
0.17
|
||||
]
|
||||
],
|
||||
"peak": 0.6,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"sample_rate": 44100,
|
||||
"seconds": 0.28,
|
||||
"sha256": "9d9297b0f681cf00490cbbb0b4316b9607002ca7e0cb2ef3d33622b52091349e"
|
||||
},
|
||||
"pace_slow.wav": {
|
||||
"file": "pace_slow.wav",
|
||||
"frames": 24696,
|
||||
"recipe": {
|
||||
"attack_seconds": 0.01,
|
||||
"notes": [
|
||||
[
|
||||
494.0,
|
||||
0.22
|
||||
],
|
||||
[
|
||||
370.0,
|
||||
0.34
|
||||
]
|
||||
],
|
||||
"peak": 0.6,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"sample_rate": 44100,
|
||||
"seconds": 0.56,
|
||||
"sha256": "5bea2e6c64b57065fb72436f8246ea6ded4e58f2c8f070a0aef3b135fb871bf6"
|
||||
},
|
||||
"workout_done.wav": {
|
||||
"file": "workout_done.wav",
|
||||
"frames": 24255,
|
||||
|
||||
@ -6,10 +6,11 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
/// The sports the app records.
|
||||
enum RunSport {
|
||||
/// A run; credited on distance OR duration by the PC.
|
||||
/// A run. The PC credits every GPS activity alike: a day's distance OR
|
||||
/// moving time, whatever the sport.
|
||||
running('Running', 'Running'),
|
||||
|
||||
/// A walk; the PC sums a day's walks and credits their moving time.
|
||||
/// A walk; read from old files only (the app records everything as a run).
|
||||
///
|
||||
/// Written as `Sport="Other"` exactly like RunnerUp does (the TCX schema
|
||||
/// only knows Running, Biking and Other); the `_Walking.tcx` file-name
|
||||
@ -79,7 +80,7 @@ class RunPoint {
|
||||
|
||||
/// Cumulative credited distance from the start, in metres.
|
||||
///
|
||||
/// The PC derives walking speed from the difference between two points'
|
||||
/// The PC derives speed from the difference between two points'
|
||||
/// values, so this is the field the recorder must keep honest.
|
||||
final double distanceM;
|
||||
|
||||
|
||||
@ -66,7 +66,9 @@ enum FixVerdict {
|
||||
///
|
||||
/// A step between two accepted points adds distance only when it is a moving
|
||||
/// step (see [isMovingStep]): a dropout longer than [kMaxCreditGapSeconds]
|
||||
/// and standing-still jitter credit nothing, same as the PC reads an activity.
|
||||
/// and standing-still jitter credit nothing. The PC judges windows of 10 s or
|
||||
/// more and keeps a dropout moving at 1 m/s or faster, so it can credit more
|
||||
/// than this does (see `run_math.dart`).
|
||||
class GpsFilter {
|
||||
/// Creates a filter; every recording uses the running jump cap.
|
||||
GpsFilter();
|
||||
|
||||
@ -38,4 +38,14 @@ class PrefsPaceTargetStore implements PaceTargetStore {
|
||||
|
||||
@override
|
||||
Future<void> save(int seconds) => _resolved.setInt(kPaceTargetKey, seconds);
|
||||
|
||||
/// The target as stored, or null when none was ever chosen (the "off"
|
||||
/// state the /sdcard backup carries).
|
||||
Future<int?> stored() => _resolved.getInt(kPaceTargetKey);
|
||||
|
||||
/// Puts a backed-up target back; null means none chosen, so a leftover
|
||||
/// value is removed rather than kept.
|
||||
Future<void> restore(int? seconds) => seconds == null
|
||||
? _resolved.remove(kPaceTargetKey)
|
||||
: save(clampPaceTarget(seconds));
|
||||
}
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
/// Distance and moving-time maths for recorded tracks (pure, no I/O).
|
||||
///
|
||||
/// The PC's one crediting rule (`screen_locker/_activity_day.py`, `_walk_tcx.py`),
|
||||
/// for every GPS activity whatever its sport: a day's activities are summed and
|
||||
/// count once the distance reaches 3.5 km (5 % tolerance) OR the moving time
|
||||
/// reaches 25 min. Only the PC decides that; the app has no "qualifies" test.
|
||||
///
|
||||
/// The PC judges speed over windows of at least 10 s (`TRACK_WINDOW_SECONDS`).
|
||||
/// A window faster than 25 km/h earns neither distance nor time; a window of
|
||||
/// 30 s or less earns moving time at 0.5 m/s or faster; a longer one (a GPS
|
||||
/// dropout) at 1 m/s or faster; paused time earns nothing.
|
||||
///
|
||||
/// The app mirrors only the bounds, per pair of its 2 s fixes, and is stricter:
|
||||
/// a pair over 30 s credits nothing, where the PC keeps a dropout at 1 m/s or
|
||||
/// faster. Its on-screen moving time is therefore an estimate, not the
|
||||
/// number the PC will credit.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
@ -12,20 +27,18 @@ const double kEarthRadiusM = 6371008.8;
|
||||
/// nor moving time: a GPS dropout whose motion is unknown.
|
||||
///
|
||||
/// Mirrors `WALK_MAX_GAP_SECONDS` in the PC's `screen_locker/_constants.py`.
|
||||
/// This is a mirror of the rule the PC applies to a walk's trackpoints, so the
|
||||
/// app never reports moving time the PC would discard -- it is NOT a crediting
|
||||
/// threshold (those stay on the PC).
|
||||
const int kMaxCreditGapSeconds = 30;
|
||||
|
||||
/// A pair slower than this is standing still, not moving.
|
||||
///
|
||||
/// Mirrors the lower bound of `WALK_SPEED_MPS` on the PC.
|
||||
/// Mirrors `MIN_MOVING_SPEED_MPS` on the PC.
|
||||
const double kMinMovingSpeedMps = 0.5;
|
||||
|
||||
/// Speed cap above which a fix is a GPS jump, 25 km/h.
|
||||
///
|
||||
/// One cap for every recording: the PC credits every GPS activity with the
|
||||
/// same rule, so the app no longer tells a run from a walk.
|
||||
/// Mirrors `MAX_MOVING_SPEED_MPS` on the PC, which applies it to each window
|
||||
/// of 10 s or more rather than to each fix. One cap for every recording: the
|
||||
/// PC credits every GPS activity with the same rule.
|
||||
const double kRunningMaxSpeedMps = 25 / 3.6;
|
||||
|
||||
double _rad(double degrees) => degrees * math.pi / 180;
|
||||
@ -46,8 +59,9 @@ double haversineM(double lat1, double lon1, double lat2, double lon2) {
|
||||
/// [kMaxCreditGapSeconds] and travelled at a speed in
|
||||
/// `[kMinMovingSpeedMps, maxSpeed]`.
|
||||
///
|
||||
/// Speed comes from the cumulative `distanceM` difference, exactly as the PC
|
||||
/// reads a TCX, so app and PC agree on which steps count.
|
||||
/// Speed comes from the cumulative `distanceM` difference, as the PC reads a
|
||||
/// TCX, but over one pair of fixes rather than a window of 10 s or more, so the
|
||||
/// two can disagree on a jittery or stop-and-go stretch.
|
||||
bool isMovingStep(RunPoint a, RunPoint b, double maxSpeed) {
|
||||
final gap = b.time.difference(a.time).inMilliseconds / 1000;
|
||||
if (gap <= 0 || gap > kMaxCreditGapSeconds) return false;
|
||||
|
||||
@ -23,8 +23,8 @@ export 'package:workout_app/services/run_phase.dart';
|
||||
///
|
||||
/// A pause ignores fixes and breaks the filter's segment, so the stretch
|
||||
/// walked while paused and the first fix after it credit nothing. Moving time
|
||||
/// is the PC's rule ([movingSeconds]) applied as fixes arrive, so the number
|
||||
/// on screen is the number the PC will credit.
|
||||
/// is [movingSeconds] applied as fixes arrive: an estimate that mirrors the
|
||||
/// PC's bounds per pair of fixes, not its 10 s windows (see `run_math.dart`).
|
||||
class RunRecorder extends ChangeNotifier {
|
||||
/// Creates a recorder; [sourceFor] builds the fix stream on [start].
|
||||
RunRecorder({
|
||||
|
||||
@ -19,6 +19,7 @@ import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/manual_workout_port.dart';
|
||||
import 'package:workout_app/services/pace_target_store.dart';
|
||||
import 'package:workout_app/services/progression_sync_service.dart';
|
||||
|
||||
// Re-exported: ExerciseState was defined here until it moved to models/, and
|
||||
|
||||
@ -51,9 +51,45 @@ extension StorageServiceBackup on StorageService {
|
||||
'settings': settingsRows,
|
||||
'progression_events': eventRows,
|
||||
'runs': runRows,
|
||||
...await _paceTargetForBackup(),
|
||||
});
|
||||
}
|
||||
|
||||
/// The pace target for the backup: `pace_target` is seconds per kilometre,
|
||||
/// or null when none was ever chosen (off). Lives in shared_preferences, not
|
||||
/// the DB, so it is added by hand. An unreadable store leaves the field out
|
||||
/// (a restore then reads "off") and says so.
|
||||
Future<Map<String, Object?>> _paceTargetForBackup() async {
|
||||
try {
|
||||
return {'pace_target': await PrefsPaceTargetStore().stored()};
|
||||
} on Object catch (error) {
|
||||
debugPrint(
|
||||
'WorkoutApp: pace target NOT in this backup -- could not read it '
|
||||
'($error). A restore will bring the pace target back as off.',
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the backup's `pace_target` back. Absent (an older backup), null or
|
||||
/// unusable all mean off, so a restore never keeps a stale target.
|
||||
Future<void> _restorePaceTarget(Object? raw) async {
|
||||
if (raw != null && raw is! int) {
|
||||
debugPrint(
|
||||
'WorkoutApp: backup pace_target is not a number ($raw) -- restoring '
|
||||
'the pace target as off.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await PrefsPaceTargetStore().restore(raw is int ? raw : null);
|
||||
} on Object catch (error) {
|
||||
debugPrint(
|
||||
'WorkoutApp: pace target NOT restored -- could not write it ($error). '
|
||||
'It stays as it was; pick the target again on the run screen.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this database has never recorded a workout on this install.
|
||||
///
|
||||
/// "Empty" means no workout history and no [last_workout_type] setting —
|
||||
@ -184,5 +220,6 @@ extension StorageServiceBackup on StorageService {
|
||||
// Absent from older backups; null merges as nothing.
|
||||
await _mergeRuns(txn, backup['runs']);
|
||||
});
|
||||
await _restorePaceTarget(backup['pace_target']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,22 +89,31 @@ class RunHarness {
|
||||
home: const SizedBox(),
|
||||
);
|
||||
|
||||
RunScreen screen({RunMapBuilder? mapBuilder}) => RunScreen(
|
||||
controllerFactory: build,
|
||||
uploader: uploader,
|
||||
paceStore: paceStore,
|
||||
mapBuilder: mapBuilder ?? fakeTileMap,
|
||||
openSettings: () async {
|
||||
settingsOpened.add(1);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
/// The screen over [mapBuilder], fake tiles by default; [realMap] leaves
|
||||
/// the screen's own default map in place.
|
||||
RunScreen screen({RunMapBuilder? mapBuilder, bool realMap = false}) =>
|
||||
RunScreen(
|
||||
controllerFactory: build,
|
||||
uploader: uploader,
|
||||
paceStore: paceStore,
|
||||
mapBuilder: realMap ? null : mapBuilder ?? fakeTileMap,
|
||||
openSettings: () async {
|
||||
settingsOpened.add(1);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
/// Pumps the app with the run screen pushed on top of an empty route.
|
||||
Future<void> pump(WidgetTester tester, {RunMapBuilder? mapBuilder}) async {
|
||||
Future<void> pump(
|
||||
WidgetTester tester, {
|
||||
RunMapBuilder? mapBuilder,
|
||||
bool realMap = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(app());
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(builder: (_) => screen(mapBuilder: mapBuilder)),
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => screen(mapBuilder: mapBuilder, realMap: realMap),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
// Without an injected map builder the run screen shows the real map slot
|
||||
// (over the disk-cached OSM source).
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/services/map_tile_cache.dart';
|
||||
import 'package:workout_app/widgets/run_map.dart';
|
||||
|
||||
import '../fake_audio_platform.dart';
|
||||
import '../fake_path_provider.dart';
|
||||
import '_run_screen_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
installFakeAudioPlatform();
|
||||
resetOsmTileProvider();
|
||||
final dir = installFakePathProvider();
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
});
|
||||
|
||||
testWidgets('the default map is the run map slot', (tester) async {
|
||||
final h = RunHarness();
|
||||
await h.pump(tester, realMap: true);
|
||||
expect(key('run_map_slot'), findsOneWidget);
|
||||
expect(find.byType(RunMap), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
// The pace target rides in the /sdcard backup and comes back on a restore.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
import 'package:shared_preferences_platform_interface/types.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/pace_band.dart';
|
||||
import 'package:workout_app/services/pace_target_store.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
import '../widgets/_run_map_fixtures.dart' show capturingDebugPrint;
|
||||
import 'storage_service_runs_fixtures.dart';
|
||||
|
||||
/// A preferences platform that can be neither read nor written.
|
||||
base class _BrokenPrefs extends InMemorySharedPreferencesAsync {
|
||||
_BrokenPrefs() : super.empty();
|
||||
|
||||
@override
|
||||
Future<int?> getInt(String key, SharedPreferencesOptions options) =>
|
||||
throw StateError('prefs unavailable');
|
||||
|
||||
@override
|
||||
Future<bool> setInt(
|
||||
String key,
|
||||
int value,
|
||||
SharedPreferencesOptions options,
|
||||
) => throw StateError('prefs unavailable');
|
||||
}
|
||||
|
||||
void _freshPrefs() => SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
|
||||
/// Writes a backup (a write triggers one) and returns it once it is on disk.
|
||||
Future<Map<String, dynamic>> _backup() async {
|
||||
await svc.setLastWorkoutType('A');
|
||||
for (var i = 0; i < 150; i++) {
|
||||
final backup = await BackupService.instance.readBackup();
|
||||
if (backup != null && (backup['settings'] as List).isNotEmpty) {
|
||||
return backup;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('backup.json never written');
|
||||
}
|
||||
|
||||
/// A reinstall: new DB, preferences holding [stale] (null: nothing), restore.
|
||||
Future<void> _reinstall({int? stale}) async {
|
||||
await freshInstall();
|
||||
_freshPrefs();
|
||||
if (stale != null) await PrefsPaceTargetStore().save(stale);
|
||||
await svc.restoreFromBackupIfNeeded();
|
||||
}
|
||||
|
||||
void main() {
|
||||
useRunsTransferSandbox();
|
||||
setUp(_freshPrefs);
|
||||
|
||||
test('a set target round-trips through backup and restore', () async {
|
||||
await PrefsPaceTargetStore().save(295);
|
||||
expect((await _backup())['pace_target'], 295);
|
||||
|
||||
await _reinstall(stale: 400);
|
||||
|
||||
expect(await PrefsPaceTargetStore().stored(), 295);
|
||||
});
|
||||
|
||||
test('"off" round-trips and replaces a stale target', () async {
|
||||
final backup = await _backup();
|
||||
expect(backup.containsKey('pace_target'), isTrue);
|
||||
expect(backup['pace_target'], isNull);
|
||||
|
||||
await _reinstall(stale: 400);
|
||||
|
||||
expect(await PrefsPaceTargetStore().stored(), isNull);
|
||||
expect(await PrefsPaceTargetStore().load(), kDefaultPaceTarget);
|
||||
});
|
||||
|
||||
test('an older backup without the field restores as off', () async {
|
||||
await BackupService.instance.export({
|
||||
'exercise_state': <Object>[],
|
||||
'workout_history': <Object>[],
|
||||
'settings': <Object>[],
|
||||
});
|
||||
|
||||
await _reinstall(stale: 400);
|
||||
|
||||
expect(await PrefsPaceTargetStore().stored(), isNull);
|
||||
});
|
||||
|
||||
test('a target that is not a number restores as off, loudly', () async {
|
||||
await BackupService.instance.export({'pace_target': 'fast'});
|
||||
final lines = await capturingDebugPrint(() => _reinstall(stale: 400));
|
||||
expect(await PrefsPaceTargetStore().stored(), isNull);
|
||||
expect(lines.where((l) => l.contains('not a number')), hasLength(1));
|
||||
});
|
||||
|
||||
test('an out-of-range target is clamped on restore', () async {
|
||||
await BackupService.instance.export({'pace_target': 60});
|
||||
await _reinstall();
|
||||
expect(await PrefsPaceTargetStore().stored(), kMinPaceTarget);
|
||||
});
|
||||
|
||||
test('unreadable preferences leave the field out and say so', () async {
|
||||
SharedPreferencesAsyncPlatform.instance = _BrokenPrefs();
|
||||
late Map<String, dynamic> backup;
|
||||
final lines = await capturingDebugPrint(
|
||||
() async => backup = await _backup(),
|
||||
);
|
||||
expect(backup.containsKey('pace_target'), isFalse);
|
||||
expect(lines.where((l) => l.contains('NOT in this backup')), hasLength(1));
|
||||
});
|
||||
|
||||
test('unwritable preferences do not stop the rest of the restore', () async {
|
||||
await BackupService.instance.export({
|
||||
'settings': [
|
||||
{'key': 'last_workout_type', 'value': 'B', 'updated_at': 1},
|
||||
],
|
||||
'pace_target': 295,
|
||||
});
|
||||
await freshInstall();
|
||||
SharedPreferencesAsyncPlatform.instance = _BrokenPrefs();
|
||||
final lines = await capturingDebugPrint(svc.restoreFromBackupIfNeeded);
|
||||
expect(await svc.getNextWorkoutType(), 'A'); // last was B: restored
|
||||
expect(lines.where((l) => l.contains('NOT restored')), hasLength(1));
|
||||
});
|
||||
}
|
||||
@ -6,11 +6,16 @@ nothing clicks. They sit apart in pitch and direction, so they can be told
|
||||
apart with the screen off and the phone in a pocket (the vibration pattern is
|
||||
the second channel).
|
||||
|
||||
Usage: ``render_pace_tones.py OUT_DIR`` writes both files into OUT_DIR.
|
||||
Usage: ``render_pace_tones.py OUT_DIR`` writes both files into OUT_DIR and
|
||||
adds their entries (recipe, frame count, SHA-256) to OUT_DIR/MANIFEST.json,
|
||||
leaving the other entries (the finish chime's) as they are. Standard library
|
||||
only, so there is no toolkit to pin and no ``.sh`` wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
@ -37,14 +42,38 @@ def _note(freq: float, seconds: float) -> list[float]:
|
||||
return samples
|
||||
|
||||
|
||||
def render(path: Path, notes: tuple[tuple[float, float], ...]) -> None:
|
||||
"""Write ``notes`` back to back as a 16-bit mono WAV at ``path``."""
|
||||
def render(path: Path, notes: tuple[tuple[float, float], ...]) -> dict[str, object]:
|
||||
"""Write ``notes`` back to back as a 16-bit mono WAV; return its manifest entry."""
|
||||
samples = [s for freq, seconds in notes for s in _note(freq, seconds)]
|
||||
with wave.open(str(path), "wb") as out:
|
||||
with wave.Wave_write(str(path)) as out:
|
||||
out.setnchannels(1)
|
||||
out.setsampwidth(2)
|
||||
out.setframerate(SAMPLE_RATE)
|
||||
out.writeframes(b"".join(struct.pack("<h", int(s * 32767)) for s in samples))
|
||||
return {
|
||||
"file": path.name,
|
||||
"frames": len(samples),
|
||||
"recipe": {
|
||||
"attack_seconds": ATTACK_SECONDS,
|
||||
"notes": [list(note) for note in notes],
|
||||
"peak": PEAK,
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
},
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"seconds": round(len(samples) / SAMPLE_RATE, 2),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def update_manifest(path: Path, entries: dict[str, dict[str, object]]) -> None:
|
||||
"""Merge ``entries`` into the JSON manifest at ``path``, keeping the rest."""
|
||||
manifest: dict[str, object] = (
|
||||
json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
)
|
||||
manifest.update(entries)
|
||||
path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
@ -54,8 +83,11 @@ def main(argv: list[str]) -> int:
|
||||
return 2
|
||||
out = Path(argv[1])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
render(out / "pace_slow.wav", SLOW_NOTES)
|
||||
render(out / "pace_fast.wav", FAST_NOTES)
|
||||
entries = {
|
||||
"pace_slow.wav": render(out / "pace_slow.wav", SLOW_NOTES),
|
||||
"pace_fast.wav": render(out / "pace_fast.wav", FAST_NOTES),
|
||||
}
|
||||
update_manifest(out / "MANIFEST.json", entries)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user