mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Add default-off local beat analysis, pitch-preserving tempo matching, crossfade fallback, and cancellation-safe two-deck playback. Expose AutoMix in settings and the Mornye queue. Separate Search from Home, remove the Mornye greeting, and keep Home/Search icons moving continuously between full and compact navigation. Restore expansion on upward Library scrolling. Validate beat analysis, transport handoff and cancellation, settings persistence, and navigation behavior. Flutter analysis and the signed iOS simulator build pass.
126 lines
4.4 KiB
Dart
126 lines
4.4 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/services/automix_analysis.dart';
|
|
|
|
Uint8List drumPattern(double bpm, {double phase = 0.17}) {
|
|
const sampleRate = 11025;
|
|
final bytes = ByteData(sampleRate * 24 * 2);
|
|
final random = math.Random(7);
|
|
for (var i = 0; i < sampleRate * 24; i++) {
|
|
final time = i / sampleRate;
|
|
final sinceBeat = (time - phase) % (60 / bpm);
|
|
final transient = time < phase ? 0.0 : math.exp(-sinceBeat * 50);
|
|
final value =
|
|
0.75 * transient * math.sin(time * 2 * math.pi * 100) +
|
|
(random.nextDouble() - 0.5) * 0.003;
|
|
bytes.setInt16(i * 2, (value * 32767).round(), Endian.little);
|
|
}
|
|
return bytes.buffer.asUint8List();
|
|
}
|
|
|
|
void main() {
|
|
for (final bpm in [90.0, 120.0, 124.0, 160.0]) {
|
|
test('detects $bpm BPM and beat phase from audio', () {
|
|
final grid = analyzeAutoMixPcm(drumPattern(bpm));
|
|
expect(grid.reliable, isTrue, reason: '${grid.bpm}, ${grid.confidence}');
|
|
expect(grid.bpm, closeTo(bpm, 0.75));
|
|
expect(grid.beatAtOrAfter(0), closeTo(0.17, 0.04));
|
|
});
|
|
}
|
|
|
|
test('silence and unstructured noise do not invent a reliable beat', () {
|
|
expect(analyzeAutoMixPcm(Uint8List(11025 * 24 * 2)).reliable, isFalse);
|
|
final random = math.Random(11);
|
|
final noise = ByteData(11025 * 24 * 2);
|
|
for (var i = 0; i < noise.lengthInBytes; i += 2) {
|
|
noise.setInt16(i, random.nextInt(32000) - 16000, Endian.little);
|
|
}
|
|
expect(analyzeAutoMixPcm(noise.buffer.asUint8List()).reliable, isFalse);
|
|
});
|
|
|
|
AutoMixBeatGrid grid(double bpm, {double confidence = 0.9}) =>
|
|
AutoMixBeatGrid(
|
|
bpm: bpm,
|
|
phase: 0.17,
|
|
confidence: confidence,
|
|
firstSound: 0.1,
|
|
);
|
|
|
|
AutoMixPlan plan(double from, double to, {double confidence = 0.9}) =>
|
|
AutoMixPlan.create(
|
|
outgoingDuration: const Duration(seconds: 180),
|
|
incomingDuration: const Duration(seconds: 200),
|
|
outro: grid(from, confidence: confidence),
|
|
intro: grid(to),
|
|
outroOffset: 156,
|
|
)!;
|
|
|
|
test('compatible tempi align outgoing and incoming beats', () {
|
|
final transition = plan(120, 124);
|
|
expect(transition.beatMatched, isTrue);
|
|
expect(transition.rate, closeTo(120 / 124, 0.00001));
|
|
final beat = (transition.start.inMicroseconds / 1e6 - 156.17) / 0.5;
|
|
expect(beat, closeTo(beat.round(), 0.00001));
|
|
expect(transition.incomingStart.inMilliseconds, 170);
|
|
expect(
|
|
transition.start + transition.duration,
|
|
lessThanOrEqualTo(const Duration(seconds: 180)),
|
|
);
|
|
});
|
|
|
|
test('incompatible or uncertain beats use unstretched crossfade', () {
|
|
for (final transition in [
|
|
plan(120, 160),
|
|
plan(120, 124, confidence: 0.2),
|
|
]) {
|
|
expect(transition.beatMatched, isFalse);
|
|
expect(transition.rate, 1);
|
|
expect(transition.incomingStart, Duration.zero);
|
|
}
|
|
expect(
|
|
AutoMixPlan.create(
|
|
outgoingDuration: const Duration(seconds: 10),
|
|
incomingDuration: const Duration(minutes: 2),
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('equal-power overlap restores normal tempo smoothly', () {
|
|
final transition = plan(120, 124);
|
|
final start = autoMixEnvelope(Duration.zero, transition);
|
|
expect(start.outgoing, 1);
|
|
expect(start.incoming, 0);
|
|
final middle = autoMixEnvelope(transition.duration ~/ 2, transition);
|
|
expect(
|
|
middle.outgoing * middle.outgoing + middle.incoming * middle.incoming,
|
|
closeTo(1, 0.00001),
|
|
);
|
|
expect(middle.rate, transition.rate);
|
|
final recovery = autoMixEnvelope(
|
|
transition.duration + const Duration(seconds: 4),
|
|
transition,
|
|
);
|
|
expect(recovery.rate, closeTo((transition.rate + 1) / 2, 0.00001));
|
|
final end = autoMixEnvelope(
|
|
transition.duration + const Duration(seconds: 8),
|
|
transition,
|
|
);
|
|
expect(end.complete, isTrue);
|
|
expect(end.rate, 1);
|
|
expect(end.incoming, 1);
|
|
expect(end.outgoing, closeTo(0, 0.00001));
|
|
});
|
|
|
|
test('AutoMix is off for new and existing settings, and persists opt-in', () {
|
|
expect(const AppSettings().autoMix, isFalse);
|
|
expect(AppSettings.fromJson({}).autoMix, isFalse);
|
|
final enabled = const AppSettings().copyWith(autoMix: true);
|
|
expect(AppSettings.fromJson(enabled.toJson()).autoMix, isTrue);
|
|
expect(enabled.copyWith(autoMix: false).autoMix, isFalse);
|
|
});
|
|
}
|