diff --git a/rhythm_coach/android/app/src/main/AndroidManifest.xml b/rhythm_coach/android/app/src/main/AndroidManifest.xml index fb4af69..292fb44 100644 --- a/rhythm_coach/android/app/src/main/AndroidManifest.xml +++ b/rhythm_coach/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/rhythm_coach/ios/Runner/Info.plist b/rhythm_coach/ios/Runner/Info.plist index 0fbd3fd..e27debb 100644 --- a/rhythm_coach/ios/Runner/Info.plist +++ b/rhythm_coach/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + NSMicrophoneUsageDescription + Le mode Music écoute la musique pour se caler sur son tempo. L'audio reste sur l'appareil. CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/rhythm_coach/lib/l10n/app_de.arb b/rhythm_coach/lib/l10n/app_de.arb index d193241..80378bb 100644 --- a/rhythm_coach/lib/l10n/app_de.arb +++ b/rhythm_coach/lib/l10n/app_de.arb @@ -953,6 +953,11 @@ "musicStop": "STOPP", "musicWaitingTempo": "Noch ein paar Mal tippen…", "musicDebugTooltip": "Debug: Gating ignorieren", + "musicSourceTap": "Tippen", + "musicSourceMic": "Mikro", + "musicListening": "Höre die Musik…", + "musicMicHint": "Starte deine Musik, dann das Zuhören.", + "musicMicDenied": "Mikrofon-Zugriff verweigert.", "customAppBarTitle": "Custom-Sessions", "customListEmptyTitle": "Noch keine gespeicherte Konfiguration", "customListEmptyBody": "Erstelle deine erste Konfiguration, um maßgeschneiderte Sessions zu generieren.", diff --git a/rhythm_coach/lib/l10n/app_en.arb b/rhythm_coach/lib/l10n/app_en.arb index 4e079ae..e5ddce5 100644 --- a/rhythm_coach/lib/l10n/app_en.arb +++ b/rhythm_coach/lib/l10n/app_en.arb @@ -953,6 +953,11 @@ "musicStop": "STOP", "musicWaitingTempo": "A few more taps…", "musicDebugTooltip": "Debug: ignore gating", + "musicSourceTap": "Tap", + "musicSourceMic": "Mic", + "musicListening": "Listening to the music…", + "musicMicHint": "Start your music, then start listening.", + "musicMicDenied": "Microphone permission denied.", "customAppBarTitle": "Custom sessions", "customListEmptyTitle": "No saved config yet", "customListEmptyBody": "Create your first config to generate tailor-made sessions.", diff --git a/rhythm_coach/lib/l10n/app_es.arb b/rhythm_coach/lib/l10n/app_es.arb index 93ab74c..d32f529 100644 --- a/rhythm_coach/lib/l10n/app_es.arb +++ b/rhythm_coach/lib/l10n/app_es.arb @@ -953,6 +953,11 @@ "musicStop": "PARAR", "musicWaitingTempo": "Unos toques más…", "musicDebugTooltip": "Debug: ignorar gating", + "musicSourceTap": "Tocar", + "musicSourceMic": "Micro", + "musicListening": "Escuchando la música…", + "musicMicHint": "Pon tu música y empieza a escuchar.", + "musicMicDenied": "Permiso de micrófono denegado.", "customAppBarTitle": "Sesiones a medida", "customListEmptyTitle": "Aún sin configuración guardada", "customListEmptyBody": "Crea tu primera configuración para generar sesiones a medida.", diff --git a/rhythm_coach/lib/l10n/app_fr.arb b/rhythm_coach/lib/l10n/app_fr.arb index f1a7f03..c5fb207 100644 --- a/rhythm_coach/lib/l10n/app_fr.arb +++ b/rhythm_coach/lib/l10n/app_fr.arb @@ -953,6 +953,11 @@ "musicStop": "STOP", "musicWaitingTempo": "Encore quelques taps…", "musicDebugTooltip": "Debug : ignorer le gating", + "musicSourceTap": "Taper", + "musicSourceMic": "Micro", + "musicListening": "J'écoute la musique…", + "musicMicHint": "Lance ta musique, puis démarre l'écoute.", + "musicMicDenied": "Permission micro refusée.", "customAppBarTitle": "Sessions custom", "customListEmptyTitle": "Aucune config enregistrée", "customListEmptyBody": "Crée ta première config pour générer des séances sur mesure.", diff --git a/rhythm_coach/lib/l10n/app_localizations.dart b/rhythm_coach/lib/l10n/app_localizations.dart index 2f04e99..df9b960 100644 --- a/rhythm_coach/lib/l10n/app_localizations.dart +++ b/rhythm_coach/lib/l10n/app_localizations.dart @@ -3474,6 +3474,36 @@ abstract class AppLocalizations { /// **'Debug : ignorer le gating'** String get musicDebugTooltip; + /// No description provided for @musicSourceTap. + /// + /// In fr, this message translates to: + /// **'Taper'** + String get musicSourceTap; + + /// No description provided for @musicSourceMic. + /// + /// In fr, this message translates to: + /// **'Micro'** + String get musicSourceMic; + + /// No description provided for @musicListening. + /// + /// In fr, this message translates to: + /// **'J\'écoute la musique…'** + String get musicListening; + + /// No description provided for @musicMicHint. + /// + /// In fr, this message translates to: + /// **'Lance ta musique, puis démarre l\'écoute.'** + String get musicMicHint; + + /// No description provided for @musicMicDenied. + /// + /// In fr, this message translates to: + /// **'Permission micro refusée.'** + String get musicMicDenied; + /// No description provided for @customAppBarTitle. /// /// In fr, this message translates to: diff --git a/rhythm_coach/lib/l10n/app_localizations_de.dart b/rhythm_coach/lib/l10n/app_localizations_de.dart index ecc6a87..f9c1619 100644 --- a/rhythm_coach/lib/l10n/app_localizations_de.dart +++ b/rhythm_coach/lib/l10n/app_localizations_de.dart @@ -1964,6 +1964,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get musicDebugTooltip => 'Debug: Gating ignorieren'; + @override + String get musicSourceTap => 'Tippen'; + + @override + String get musicSourceMic => 'Mikro'; + + @override + String get musicListening => 'Höre die Musik…'; + + @override + String get musicMicHint => 'Starte deine Musik, dann das Zuhören.'; + + @override + String get musicMicDenied => 'Mikrofon-Zugriff verweigert.'; + @override String get customAppBarTitle => 'Custom-Sessions'; diff --git a/rhythm_coach/lib/l10n/app_localizations_en.dart b/rhythm_coach/lib/l10n/app_localizations_en.dart index eb211c7..3860301 100644 --- a/rhythm_coach/lib/l10n/app_localizations_en.dart +++ b/rhythm_coach/lib/l10n/app_localizations_en.dart @@ -1954,6 +1954,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get musicDebugTooltip => 'Debug: ignore gating'; + @override + String get musicSourceTap => 'Tap'; + + @override + String get musicSourceMic => 'Mic'; + + @override + String get musicListening => 'Listening to the music…'; + + @override + String get musicMicHint => 'Start your music, then start listening.'; + + @override + String get musicMicDenied => 'Microphone permission denied.'; + @override String get customAppBarTitle => 'Custom sessions'; diff --git a/rhythm_coach/lib/l10n/app_localizations_es.dart b/rhythm_coach/lib/l10n/app_localizations_es.dart index a4b8049..17247ad 100644 --- a/rhythm_coach/lib/l10n/app_localizations_es.dart +++ b/rhythm_coach/lib/l10n/app_localizations_es.dart @@ -1962,6 +1962,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get musicDebugTooltip => 'Debug: ignorar gating'; + @override + String get musicSourceTap => 'Tocar'; + + @override + String get musicSourceMic => 'Micro'; + + @override + String get musicListening => 'Escuchando la música…'; + + @override + String get musicMicHint => 'Pon tu música y empieza a escuchar.'; + + @override + String get musicMicDenied => 'Permiso de micrófono denegado.'; + @override String get customAppBarTitle => 'Sesiones a medida'; diff --git a/rhythm_coach/lib/l10n/app_localizations_fr.dart b/rhythm_coach/lib/l10n/app_localizations_fr.dart index 4e87bb1..30fa031 100644 --- a/rhythm_coach/lib/l10n/app_localizations_fr.dart +++ b/rhythm_coach/lib/l10n/app_localizations_fr.dart @@ -1964,6 +1964,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get musicDebugTooltip => 'Debug : ignorer le gating'; + @override + String get musicSourceTap => 'Taper'; + + @override + String get musicSourceMic => 'Micro'; + + @override + String get musicListening => 'J\'écoute la musique…'; + + @override + String get musicMicHint => 'Lance ta musique, puis démarre l\'écoute.'; + + @override + String get musicMicDenied => 'Permission micro refusée.'; + @override String get customAppBarTitle => 'Sessions custom'; diff --git a/rhythm_coach/lib/music/mic_capture.dart b/rhythm_coach/lib/music/mic_capture.dart new file mode 100644 index 0000000..6646658 --- /dev/null +++ b/rhythm_coach/lib/music/mic_capture.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:record/record.dart'; + +/// Couche de capture micro (la **seule** qui dépend du natif `record`). +/// Diffuse des samples PCM mono `−1..1` vers un callback ; le reste de la +/// chaîne (`OnsetDetector` → `TempoTracker` → `MicTempoSource`) est pur. +/// +/// Hors ligne : capture locale du micro, aucun réseau. `echoCancel` + +/// `noiseSuppress` demandent à l'OS d'atténuer nos propres bips dans l'entrée. +class MicCapture { + final int sampleRate; + final AudioRecorder _rec = AudioRecorder(); + StreamSubscription? _sub; + + MicCapture({this.sampleRate = 44100}); + + Future hasPermission() => _rec.hasPermission(); + + /// Démarre la capture et appelle [onPcm] à chaque bloc (samples mono −1..1). + /// Retourne `false` si la permission micro est refusée. + Future start(void Function(List samples) onPcm) async { + if (!await _rec.hasPermission()) return false; + final stream = await _rec.startStream( + RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: sampleRate, + numChannels: 1, + echoCancel: true, + noiseSuppress: true, + ), + ); + _sub = stream.listen((bytes) => onPcm(decodePcm16(bytes))); + return true; + } + + /// Décode du PCM 16 bits little-endian en samples `double` (−1..1). Pur. + static List decodePcm16(Uint8List bytes) { + final n = bytes.length ~/ 2; + final out = List.filled(n, 0); + final bd = ByteData.sublistView(bytes); + for (var i = 0; i < n; i++) { + out[i] = bd.getInt16(i * 2, Endian.little) / 32768.0; + } + return out; + } + + Future stop() async { + await _sub?.cancel(); + _sub = null; + if (await _rec.isRecording()) await _rec.stop(); + } + + Future dispose() async { + await stop(); + await _rec.dispose(); + } +} diff --git a/rhythm_coach/lib/music/mic_tempo_source.dart b/rhythm_coach/lib/music/mic_tempo_source.dart new file mode 100644 index 0000000..4f39424 --- /dev/null +++ b/rhythm_coach/lib/music/mic_tempo_source.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'beat_clock.dart'; +import 'beat_grid.dart'; +import 'onset_detector.dart'; +import 'tempo_tracker.dart'; + +/// Source de tempo « micro » (PR2) — détecte le tempo de la musique ambiante +/// (cf. `specs/music_mode.md`). **Hors ligne** : tout se passe sur l'appareil. +/// +/// Le cœur est [feedPcm] (pur, testable) : PCM → onsets → tempo → battements. +/// La capture micro réelle (`record`) n'est qu'une fine couche qui appelle +/// [feedPcm] (cf. `MicCapture`). L'horloge maître est l'horloge **échantillons** +/// (`OnsetDetector.elapsedMs`), donc tout est piloté par l'arrivée du PCM — pas +/// de `Timer`, et c'est déterministe en test. +/// +/// Cycle : **intro/calibration** (écoute sans émettre tant que le tempo n'est +/// pas verrouillé avec assez de confiance) → **lecture** (émet les battements, +/// re-cale doucement la dérive). Gating : [gate] ignore les onsets pendant une +/// fenêtre (appelé par le contrôleur quand l'app joue un bip). +class MicTempoSource implements BeatClock { + final OnsetDetector _detector; + final TempoTracker _tracker; + + /// Confiance minimale pour verrouiller / re-caler le tempo. + final double lockConfidence; + + final StreamController _ctrl = + StreamController.broadcast(); + BeatScheduler? _scheduler; + int _lastElapsed = 0; + int _gateUntil = 0; + double? _bpm; + + MicTempoSource({ + OnsetDetector? detector, + TempoTracker? tracker, + this.lockConfidence = 0.85, + }) : _detector = detector ?? OnsetDetector(), + _tracker = tracker ?? TempoTracker(); + + /// Vrai tant que le tempo n'est pas verrouillé (phase d'intro). + bool get isCalibrating => _scheduler == null; + + @override + Stream get ticks => _ctrl.stream; + + @override + double? get bpm => _bpm; + + @override + bool get isRunning => _scheduler != null; + + /// Ignore les onsets pendant [windowMs] à partir de maintenant (gating des + /// bips de l'app). + void gate(int windowMs) { + final until = _lastElapsed + windowMs; + if (until > _gateUntil) _gateUntil = until; + } + + /// Cœur : pousse des samples PCM mono (−1..1). + void feedPcm(List samples) { + final onsets = _detector.process(samples); + _lastElapsed = _detector.elapsedMs; + for (final o in onsets) { + if (o >= _gateUntil) _tracker.addOnset(o); + } + + final est = _tracker.estimate(); + if (est != null && est.confidence >= lockConfidence) { + final grid = BeatGrid(bpm: est.bpm, anchorMs: est.anchorMs); + if (_scheduler == null) { + _scheduler = BeatScheduler(grid); // verrou initial : fin de l'intro + } else { + _scheduler!.retune(grid, _lastElapsed); // re-cale la dérive + } + _bpm = est.bpm; + } + + final s = _scheduler; + if (s != null) { + for (final t in s.poll(_lastElapsed)) { + _ctrl.add(t); + } + } + } + + // L'émission est pilotée par le flux PCM ; start/stop sont des no-op côté + // horloge (la capture micro est gérée par la couche `MicCapture`). + @override + void start() {} + + @override + void stop() {} + + @override + void dispose() => _ctrl.close(); +} diff --git a/rhythm_coach/lib/music/music_session_controller.dart b/rhythm_coach/lib/music/music_session_controller.dart index 547cb85..5c5a582 100644 --- a/rhythm_coach/lib/music/music_session_controller.dart +++ b/rhythm_coach/lib/music/music_session_controller.dart @@ -4,32 +4,55 @@ import 'package:flutter/foundation.dart'; import '../models/session_step.dart' show Position; import '../services/beep_engine.dart'; -import '../services/capability_service.dart'; +import '../services/capability_service.dart' show CapabilityProfile; +import 'beat_clock.dart'; import 'beat_pattern.dart'; +import 'mic_capture.dart'; +import 'mic_tempo_source.dart'; import 'music_pattern_generator.dart'; import 'music_session_engine.dart'; import 'slot_action.dart'; import 'tap_tempo.dart'; -/// Contrôleur autonome du mode Music (cf. `specs/music_mode.md`). Ne réutilise -/// **pas** le `SessionController` carrière : il câble simplement -/// `TapTempoSource` → `MusicSessionEngine` → `BeepEngine`. +/// Contrôleur autonome du mode Music (cf. `specs/music_mode.md`). Câble une +/// source de tempo (**tap** ou **micro**) → `MusicSessionEngine` → `BeepEngine`. /// -/// Mapping audio des actions de slot (PR1) : -/// - `strike` → impact positionnel (`playPositionOnce`) -/// - `hold` → overlay tenu (`playHoldOnce`) -/// - `release`→ muet (la remontée est silencieuse, cf. §5.1) +/// Mapping audio des actions de slot : +/// - frappe simple → impact positionnel (`playPositionOnce`) +/// - frappe amorçant un hold → overlay tenu (`playHoldOnce`), 1 fois +/// - hold (temps suivants) → muet +/// - ancre (`release`) → tick léger (`playPositionOnce(tip)`) class MusicSessionController extends ChangeNotifier { final BeepEngine beep; - final TapTempoSource _clock = TapTempoSource(); + + /// Vraie pour la source **micro** (détection auto), fausse pour le **tap**. + final bool useMic; + + late final BeatClock _clock; + TapTempoSource? _tap; + MicTempoSource? _mic; + MicCapture? _capture; + late final MusicSessionEngine _engine; StreamSubscription? _actionSub; + /// Fenêtre de gating (ms) appliquée au micro autour de chaque bip joué. + static const int _gateMs = 70; + MusicSessionController({ required this.beep, CapabilityProfile? profile, bool ignoreGating = false, + this.useMic = false, }) { + if (useMic) { + _mic = MicTempoSource(); + _capture = MicCapture(); + _clock = _mic!; + } else { + _tap = TapTempoSource(); + _clock = _tap!; + } _engine = MusicSessionEngine( generator: MusicPatternGenerator( profile: profile, @@ -42,9 +65,14 @@ class MusicSessionController extends ChangeNotifier { double? get bpm => _clock.bpm; bool get isRunning => _clock.isRunning; - bool get hasTempo => _clock.hasTempo; - bool get isStable => _clock.isStable; - int get tapCount => _clock.tapCount; + + /// Micro : vrai pendant l'intro (écoute jusqu'au verrou du tempo). + bool get isCalibrating => _mic?.isCalibrating ?? false; + + // Spécifiques au tap. + bool get hasTempo => _tap?.hasTempo ?? false; + bool get isStable => _tap?.isStable ?? false; + int get tapCount => _tap?.tapCount ?? 0; /// Figure courante (pour l'affichage du pattern). BeatPattern? get currentPattern => _engine.currentPattern; @@ -59,39 +87,50 @@ class MusicSessionController extends ChangeNotifier { return Duration(milliseconds: (_engine.beatsPerSlot * 60000 / b).round()); } - /// Enregistre un tap (la joueuse tape le rythme de sa musique). + /// Tap (mode tap uniquement). void tap() { - _clock.tap(); + _tap?.tap(); notifyListeners(); } - /// Démarre l'émission (nécessite un tempo établi). - void start() { - if (!_clock.hasTempo) return; - _clock.start(); + /// Démarre. Tap : nécessite un tempo établi. Micro : lance la capture + /// (demande la permission) ; retourne `false` si la permission est refusée. + Future start() async { + if (useMic) { + final ok = await _capture!.start(_mic!.feedPcm); + notifyListeners(); + return ok; + } + if (!_tap!.hasTempo) return false; + _tap!.start(); notifyListeners(); + return true; } void stop() { - _clock.stop(); + if (useMic) { + _capture!.stop(); + } else { + _tap!.stop(); + } notifyListeners(); } void _onAction(SlotAction a) { switch (a.kind) { case SlotActionKind.strike: - // Une frappe qui amorce un hold sonne comme un hold (1ᵉʳ temps - // seulement) ; une frappe simple sonne comme une plongée. if (a.sustained) { beep.playHoldOnce(a.depth); } else { beep.playPositionOnce(a.depth); } case SlotActionKind.hold: - break; // temps de hold suivants : muets (le hold a déjà sonné) + break; // temps de hold suivants : muets case SlotActionKind.release: beep.playPositionOnce(Position.tip); // l'ancre sonne (tick léger) } + // Gating : on vient de jouer un bip → ignorer le micro un court instant. + _mic?.gate(_gateMs); notifyListeners(); } @@ -99,6 +138,7 @@ class MusicSessionController extends ChangeNotifier { void dispose() { _actionSub?.cancel(); _engine.dispose(); + _capture?.dispose(); _clock.dispose(); super.dispose(); } diff --git a/rhythm_coach/lib/music/onset_detector.dart b/rhythm_coach/lib/music/onset_detector.dart new file mode 100644 index 0000000..657906c --- /dev/null +++ b/rhythm_coach/lib/music/onset_detector.dart @@ -0,0 +1,71 @@ +/// Détection d'onsets (attaques) dans un flux PCM mono (pur, testable). +/// +/// Méthode simple et robuste : **flux d'énergie** par trame + **seuil +/// adaptatif**. Pour chaque trame on calcule l'énergie ; la montée d'énergie +/// (flux, demi-rectifiée) dépassant une moyenne glissante × `threshold` +/// déclenche un onset (avec période réfractaire `minIntervalMs`). Suffisant +/// pour suivre le kick/snare d'une musique ; le spectral-flux (FFT) pourra +/// l'améliorer plus tard. +class OnsetDetector { + final int sampleRate; + final int frameSize; + final double threshold; + final int minIntervalMs; + final double alpha; // lissage EMA du flux + + double _prevEnergy = 0; + double _fluxAvg = 0; + int _lastOnsetMs = -1 << 30; + int _samplePos = 0; + final List _buf = []; + + OnsetDetector({ + this.sampleRate = 44100, + this.frameSize = 1024, + this.threshold = 2.0, + this.minIntervalMs = 120, + this.alpha = 0.9, + }); + + /// Pousse des samples PCM mono (−1..1) ; rend les timestamps (ms) des onsets + /// détectés. Les trames incomplètes sont gardées entre les appels (streaming). + List process(List samples) { + final onsets = []; + _buf.addAll(samples); + while (_buf.length >= frameSize) { + var energy = 0.0; + for (var i = 0; i < frameSize; i++) { + final s = _buf[i]; + energy += s * s; + } + energy /= frameSize; + _buf.removeRange(0, frameSize); + + final flux = energy > _prevEnergy ? energy - _prevEnergy : 0.0; + _prevEnergy = energy; + final frameMs = (_samplePos * 1000 / sampleRate).round(); + _samplePos += frameSize; + + if (flux > _fluxAvg * threshold && + flux > 1e-5 && + frameMs - _lastOnsetMs >= minIntervalMs) { + onsets.add(frameMs); + _lastOnsetMs = frameMs; + } + _fluxAvg = alpha * _fluxAvg + (1 - alpha) * flux; + } + return onsets; + } + + /// Temps courant (ms) selon les samples consommés — utile pour caler une + /// horloge sur la même base temporelle que les onsets. + int get elapsedMs => (_samplePos * 1000 / sampleRate).round(); + + void reset() { + _prevEnergy = 0; + _fluxAvg = 0; + _lastOnsetMs = -1 << 30; + _samplePos = 0; + _buf.clear(); + } +} diff --git a/rhythm_coach/lib/music/tempo_tracker.dart b/rhythm_coach/lib/music/tempo_tracker.dart new file mode 100644 index 0000000..4b7eb23 --- /dev/null +++ b/rhythm_coach/lib/music/tempo_tracker.dart @@ -0,0 +1,95 @@ +import 'dart:math'; + +/// Résultat d'une estimation de tempo (cf. `specs/music_mode.md` PR2). +class TempoEstimate { + /// Tempo estimé (battements/minute). + final double bpm; + + /// Phase : temps (ms) d'un battement, modulo la période. Les battements + /// tombent à `anchorMs + k × 60000/bpm`. + final int anchorMs; + + /// Confiance ∈ [0, 1] : à quel point les onsets sont alignés sur cette + /// période (1 = parfaitement réguliers). + final double confidence; + + const TempoEstimate({ + required this.bpm, + required this.anchorMs, + required this.confidence, + }); +} + +/// Estimateur de tempo (pur, testable). On lui pousse des **onsets** +/// (timestamps ms d'attaques détectées dans l'audio) ; il rend le BPM + la +/// phase + une confiance. +/// +/// Méthode — **statistiques circulaires** : pour chaque période candidate `T`, +/// on projette chaque onset sur le cercle d'angle `2π·onset/T` et on mesure la +/// **longueur résultante** (1 = onsets parfaitement en phase à cette période, +/// ~0 = dispersés). La période la plus alignée gagne ; sa longueur résultante +/// sert de confiance et son angle moyen donne la phase. Octave : la plage BPM +/// bornée limite le repli moitié/double ; à égalité on garde le tempo le plus +/// lent (le débit d'onsets). +class TempoTracker { + final double minBpm; + final double maxBpm; + final double stepBpm; + + /// Fenêtre glissante d'onsets considérés. + final int windowMs; + + /// Minimum d'onsets pour tenter une estimation. + final int minOnsets; + + final List _onsets = []; + + TempoTracker({ + this.minBpm = 70, + this.maxBpm = 160, + this.stepBpm = 0.5, + this.windowMs = 6000, + this.minOnsets = 6, + }); + + void addOnset(int ms) { + _onsets.add(ms); + final cutoff = ms - windowMs; + _onsets.removeWhere((t) => t < cutoff); + } + + void clear() => _onsets.clear(); + + int get onsetCount => _onsets.length; + + TempoEstimate? estimate() { + if (_onsets.length < minOnsets) return null; + var bestStrength = -1.0; + var bestBpm = 0.0; + var bestPhase = 0.0; + for (var bpm = minBpm; bpm <= maxBpm; bpm += stepBpm) { + final t = 60000.0 / bpm; + var re = 0.0, im = 0.0; + for (final o in _onsets) { + final ph = 2 * pi * (o / t); + re += cos(ph); + im += sin(ph); + } + final strength = sqrt(re * re + im * im) / _onsets.length; + if (strength > bestStrength) { + bestStrength = strength; + bestBpm = bpm; + bestPhase = atan2(im, re); // -π..π + } + } + final t = 60000.0 / bestBpm; + var anchor = (bestPhase / (2 * pi)) * t; + anchor %= t; + if (anchor < 0) anchor += t; + return TempoEstimate( + bpm: bestBpm, + anchorMs: anchor.round(), + confidence: bestStrength, + ); + } +} diff --git a/rhythm_coach/lib/screens/music_mode_screen.dart b/rhythm_coach/lib/screens/music_mode_screen.dart index a3c7c37..be75f26 100644 --- a/rhythm_coach/lib/screens/music_mode_screen.dart +++ b/rhythm_coach/lib/screens/music_mode_screen.dart @@ -23,6 +23,8 @@ class _MusicModeScreenState extends State { MusicSessionController? _controller; CapabilityProfile? _profile; bool _debug = false; + bool _useMic = false; + bool _started = false; // micro : capture lancée (écoute/lecture) @override void initState() { @@ -46,18 +48,48 @@ class _MusicModeScreenState extends State { beep: widget.beep, profile: _profile, ignoreGating: _debug, + useMic: _useMic, ); - /// Bascule le mode debug (ignore le gating) — recrée le contrôleur, il faut - /// retaper le tempo. - void _toggleDebug() { + void _rebuild() { + _controller?.dispose(); + _controller = _make(); + _started = false; + } + + /// Bascule le mode debug (ignore le gating) — recrée le contrôleur. + void _toggleDebug() => setState(() { + _debug = !_debug; + _rebuild(); + }); + + /// Bascule source tap ↔ micro — recrée le contrôleur. + void _setMic(bool mic) { + if (mic == _useMic) return; setState(() { - _debug = !_debug; - _controller?.dispose(); - _controller = _make(); + _useMic = mic; + _rebuild(); }); } + /// Démarre (tap : nécessite un tempo ; micro : lance l'écoute + permission). + Future _onStart(MusicSessionController c) async { + final ok = await c.start(); + if (!mounted) return; + if (_useMic && !ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).musicMicDenied)), + ); + return; + } + setState(() => _started = true); + } + + void _onStop(MusicSessionController c) { + c.stop(); + setState(() => _started = false); + } + @override void dispose() { // On ne dispose que le contrôleur : le BeepEngine est partagé (propriété @@ -98,12 +130,32 @@ class _MusicModeScreenState extends State { Widget _body(AppLocalizations t, MusicSessionController c) { final bpm = c.bpm; + final locked = _started || c.isRunning; + final running = _useMic ? _started : c.isRunning; + final canStart = _useMic || c.hasTempo; return Padding( padding: const EdgeInsets.all(20), child: Column( children: [ + SegmentedButton( + segments: [ + ButtonSegment( + value: false, + label: Text(t.musicSourceTap), + icon: const Icon(Icons.touch_app_outlined), + ), + ButtonSegment( + value: true, + label: Text(t.musicSourceMic), + icon: const Icon(Icons.mic_none_outlined), + ), + ], + selected: {_useMic}, + onSelectionChanged: locked ? null : (s) => _setMic(s.first), + ), + const SizedBox(height: 12), Text( - t.musicTapPrompt, + _useMic ? t.musicMicHint : t.musicTapPrompt, style: const TextStyle(color: AppTheme.textSecondary), textAlign: TextAlign.center, ), @@ -117,40 +169,9 @@ class _MusicModeScreenState extends State { ), ), const SizedBox(height: 16), - Expanded( - child: GestureDetector( - onTap: c.isRunning ? null : c.tap, - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(16), - border: - Border.all(color: AppTheme.accent.withValues(alpha: 0.4)), - ), - clipBehavior: Clip.antiAlias, - child: c.isRunning && c.currentPattern != null - ? _PatternView( - pattern: c.currentPattern!, - cursor: c.currentSlot, - slotInterval: - c.slotInterval ?? const Duration(milliseconds: 400), - ) - : Center( - child: Text( - t.musicTapAction, - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 22, - letterSpacing: 2, - ), - ), - ), - ), - ), - ), + Expanded(child: _box(t, c)), const SizedBox(height: 16), - if (!c.isStable && !c.isRunning) + if (!_useMic && !c.isStable && !c.isRunning) Text( t.musicWaitingTempo, style: const TextStyle(color: AppTheme.textMuted), @@ -159,14 +180,73 @@ class _MusicModeScreenState extends State { SizedBox( width: double.infinity, child: FilledButton( - onPressed: !c.hasTempo ? null : (c.isRunning ? c.stop : c.start), - child: Text(c.isRunning ? t.musicStop : t.musicStart), + onPressed: !canStart + ? null + : (running ? () => _onStop(c) : () => _onStart(c)), + child: Text(running ? t.musicStop : t.musicStart), ), ), ], ), ); } + + Widget _box(AppLocalizations t, MusicSessionController c) { + final Widget content; + if (c.isRunning && c.currentPattern != null) { + content = _PatternView( + pattern: c.currentPattern!, + cursor: c.currentSlot, + slotInterval: c.slotInterval ?? const Duration(milliseconds: 400), + ); + } else if (_useMic && _started) { + // Intro micro : on écoute jusqu'au verrou du tempo. + content = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.graphic_eq, color: AppTheme.accent, size: 40), + const SizedBox(height: 12), + Text(t.musicListening, + style: const TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 16), + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ); + } else if (_useMic) { + content = const Icon(Icons.mic_none_outlined, + color: AppTheme.textMuted, size: 48); + } else { + content = Text( + t.musicTapAction, + style: const TextStyle( + color: AppTheme.textMuted, + fontSize: 22, + letterSpacing: 2, + ), + ); + } + + final box = Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.accent.withValues(alpha: 0.4)), + ), + clipBehavior: Clip.antiAlias, + child: Center(child: content), + ); + + // Mode tap, pas encore lancé : taper dans le cadre pose le tempo. + if (!_useMic && !c.isRunning) { + return GestureDetector(onTap: c.tap, child: box); + } + return box; + } } /// Affichage du pattern courant (style séquenceur, debug + lisibilité) : une diff --git a/rhythm_coach/linux/flutter/generated_plugin_registrant.cc b/rhythm_coach/linux/flutter/generated_plugin_registrant.cc index 0a84174..54c28cf 100644 --- a/rhythm_coach/linux/flutter/generated_plugin_registrant.cc +++ b/rhythm_coach/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/rhythm_coach/linux/flutter/generated_plugins.cmake b/rhythm_coach/linux/flutter/generated_plugins.cmake index 44e2000..c1bf827 100644 --- a/rhythm_coach/linux/flutter/generated_plugins.cmake +++ b/rhythm_coach/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_saver file_selector_linux + record_linux url_launcher_linux ) diff --git a/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift b/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift index 50ee39c..7b54e82 100644 --- a/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_selector_macos import flutter_local_notifications import flutter_tts import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import wakelock_plus @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) diff --git a/rhythm_coach/pubspec.lock b/rhythm_coach/pubspec.lock index 1694dc7..b384915 100644 --- a/rhythm_coach/pubspec.lock +++ b/rhythm_coach/pubspec.lock @@ -757,6 +757,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" record_use: dependency: transitive description: @@ -765,6 +813,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" sensors_plus: dependency: "direct main" description: diff --git a/rhythm_coach/pubspec.yaml b/rhythm_coach/pubspec.yaml index 6f544cb..fa6a292 100644 --- a/rhythm_coach/pubspec.yaml +++ b/rhythm_coach/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: provider: ^6.1.2 wakelock_plus: ^1.6.1 audioplayers: ^6.1.0 + record: ^6.0.0 shared_preferences: ^2.3.0 camera: ^0.12.0+1 google_mlkit_face_detection: ^0.13.0 diff --git a/rhythm_coach/test/music_mic_capture_test.dart b/rhythm_coach/test/music_mic_capture_test.dart new file mode 100644 index 0000000..978e30f --- /dev/null +++ b/rhythm_coach/test/music_mic_capture_test.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:beat_bitch/music/mic_capture.dart'; + +void main() { + group('MicCapture.decodePcm16', () { + test('décode du PCM16 little-endian en −1..1', () { + // 0 → 0.0 ; 32767 → ~+1 ; -32768 → -1.0 + final bytes = Uint8List.fromList([ + 0x00, 0x00, // 0 + 0xFF, 0x7F, // 32767 + 0x00, 0x80, // -32768 + ]); + final s = MicCapture.decodePcm16(bytes); + expect(s.length, 3); + expect(s[0], 0.0); + expect(s[1], closeTo(1.0, 0.001)); + expect(s[2], -1.0); + }); + + test('octet impair en trop ignoré (tronque au sample complet)', () { + final bytes = Uint8List.fromList([0x10, 0x20, 0x05]); + expect(MicCapture.decodePcm16(bytes).length, 1); + }); + }); +} diff --git a/rhythm_coach/test/music_mic_tempo_source_test.dart b/rhythm_coach/test/music_mic_tempo_source_test.dart new file mode 100644 index 0000000..4b1f962 --- /dev/null +++ b/rhythm_coach/test/music_mic_tempo_source_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:beat_bitch/music/beat_clock.dart'; +import 'package:beat_bitch/music/mic_tempo_source.dart'; + +const _sr = 44100; + +List _clickTrack({ + required double bpm, + required int durationMs, + int burstSamples = 1500, +}) { + final n = (_sr * durationMs / 1000).round(); + final sig = List.filled(n, 0); + final periodSamples = (60.0 / bpm * _sr).round(); + for (var start = 0; start < n; start += periodSamples) { + for (var i = 0; i < burstSamples && start + i < n; i++) { + sig[start + i] = 0.6; + } + } + return sig; +} + +void _feedInChunks(MicTempoSource src, List sig, {int chunk = 2048}) { + for (var i = 0; i < sig.length; i += chunk) { + src.feedPcm(sig.sublist(i, (i + chunk).clamp(0, sig.length))); + } +} + +void main() { + group('MicTempoSource.feedPcm', () { + test('intro → verrouille le tempo et émet des battements', () async { + final src = MicTempoSource(); + final ticks = []; + src.ticks.listen(ticks.add); + + expect(src.isCalibrating, isTrue); + _feedInChunks(src, _clickTrack(bpm: 120, durationMs: 6000)); + await pumpEventQueue(); + + expect(src.isCalibrating, isFalse, reason: 'aurait dû verrouiller'); + expect(src.isRunning, isTrue); + expect(src.bpm, closeTo(120, 2.0)); + expect(ticks, isNotEmpty); + // Les battements émis sont monotones et bien formés. + for (var i = 1; i < ticks.length; i++) { + expect(ticks[i].beatIndex, greaterThan(ticks[i - 1].beatIndex)); + } + src.dispose(); + }); + + test('silence : reste en calibration, aucun battement', () async { + final src = MicTempoSource(); + final ticks = []; + src.ticks.listen(ticks.add); + + _feedInChunks(src, List.filled(_sr * 4, 0)); + await pumpEventQueue(); + + expect(src.isCalibrating, isTrue); + expect(ticks, isEmpty); + src.dispose(); + }); + + test('gate : ignore les onsets pendant la fenêtre', () { + final src = MicTempoSource(); + // Gate tout de suite une grande fenêtre, puis pousse un burst : l'onset + // tombant dans la fenêtre ne doit pas alimenter le tracker (reste calé). + src.gate(100000); + _feedInChunks(src, _clickTrack(bpm: 120, durationMs: 6000)); + expect(src.isCalibrating, isTrue, reason: 'onsets gatés → pas de verrou'); + src.dispose(); + }); + + test('suit un changement de tempo (re-cale)', () async { + final src = MicTempoSource(); + src.ticks.listen((_) {}); + _feedInChunks(src, _clickTrack(bpm: 100, durationMs: 6000)); + await pumpEventQueue(); + expect(src.bpm, closeTo(100, 2.0)); + + _feedInChunks(src, _clickTrack(bpm: 140, durationMs: 8000)); + await pumpEventQueue(); + expect(src.bpm, closeTo(140, 2.0)); + src.dispose(); + }); + }); +} diff --git a/rhythm_coach/test/music_onset_detector_test.dart b/rhythm_coach/test/music_onset_detector_test.dart new file mode 100644 index 0000000..ceed42c --- /dev/null +++ b/rhythm_coach/test/music_onset_detector_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:beat_bitch/music/onset_detector.dart'; + +const _sr = 44100; + +/// Construit un signal PCM : un burst (amplitude 0.6, `burstSamples`) à chaque +/// battement, silence entre. `bpm` battements/min, `durationMs` total. +List _clickTrack({ + required double bpm, + required int durationMs, + int burstSamples = 1500, +}) { + final n = (_sr * durationMs / 1000).round(); + final sig = List.filled(n, 0); + final periodSamples = (60.0 / bpm * _sr).round(); + for (var start = 0; start < n; start += periodSamples) { + for (var i = 0; i < burstSamples && start + i < n; i++) { + sig[start + i] = 0.6; + } + } + return sig; +} + +void main() { + group('OnsetDetector', () { + test('click-track 120 BPM → un onset par battement, bien placé', () { + final det = OnsetDetector(); + final onsets = det.process(_clickTrack(bpm: 120, durationMs: 4000)); + // 4 s à 120 BPM = 8 battements (à ±1 selon les bords). + expect(onsets.length, inInclusiveRange(7, 9)); + // Chaque onset proche d'un multiple de 500 ms (résolution ~ 1 trame). + for (final o in onsets) { + final nearest = (o / 500).round() * 500; + expect((o - nearest).abs(), lessThan(35), + reason: 'onset $o loin du battement $nearest'); + } + }); + + test('silence → aucun onset', () { + final det = OnsetDetector(); + final onsets = det.process(List.filled(_sr * 2, 0)); + expect(onsets, isEmpty); + }); + + test('streaming par petits blocs = même résultat qu’en un coup', () { + final sig = _clickTrack(bpm: 100, durationMs: 3000); + + final whole = OnsetDetector().process(sig); + + final det = OnsetDetector(); + final streamed = []; + const block = 777; // taille non alignée sur la trame + for (var i = 0; i < sig.length; i += block) { + streamed.addAll( + det.process(sig.sublist(i, (i + block).clamp(0, sig.length))), + ); + } + expect(streamed, whole); + }); + + test('alimente le tempo : intervalles ~ réguliers à 140 BPM', () { + final det = OnsetDetector(); + final onsets = det.process(_clickTrack(bpm: 140, durationMs: 4000)); + expect(onsets.length, greaterThanOrEqualTo(8)); + const period = 60000 / 140; // ~428 ms + for (var i = 1; i < onsets.length; i++) { + expect((onsets[i] - onsets[i - 1] - period).abs(), lessThan(35)); + } + }); + }); +} diff --git a/rhythm_coach/test/music_tempo_tracker_test.dart b/rhythm_coach/test/music_tempo_tracker_test.dart new file mode 100644 index 0000000..6a0cf95 --- /dev/null +++ b/rhythm_coach/test/music_tempo_tracker_test.dart @@ -0,0 +1,89 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:beat_bitch/music/tempo_tracker.dart'; + +/// Génère des onsets réguliers (BPM/phase donnés) avec un jitter optionnel. +List _clicks({ + required double bpm, + required int phaseMs, + required int count, + int jitterMs = 0, + Random? rng, +}) { + final t = 60000.0 / bpm; + return [ + for (var i = 0; i < count; i++) + (phaseMs + i * t).round() + + (jitterMs == 0 ? 0 : (rng!.nextInt(2 * jitterMs + 1) - jitterMs)), + ]; +} + +void main() { + group('TempoTracker', () { + test('train propre 120 BPM → bpm ~120, phase ~100, confiance haute', () { + final tr = TempoTracker(); + for (final o in _clicks(bpm: 120, phaseMs: 100, count: 16)) { + tr.addOnset(o); + } + final e = tr.estimate()!; + expect(e.bpm, closeTo(120, 1.0)); + expect(e.confidence, greaterThan(0.95)); + // phase modulo la période (500 ms) + expect(e.anchorMs % 500, closeTo(100, 8)); + }); + + test('train propre 90 BPM détecté', () { + final tr = TempoTracker(); + for (final o in _clicks(bpm: 90, phaseMs: 0, count: 16)) { + tr.addOnset(o); + } + expect(tr.estimate()!.bpm, closeTo(90, 1.0)); + }); + + test('robuste à un jitter modéré (±15 ms)', () { + final tr = TempoTracker(); + final rng = Random(7); + for (final o in _clicks( + bpm: 128, phaseMs: 50, count: 24, jitterMs: 15, rng: rng)) { + tr.addOnset(o); + } + final e = tr.estimate()!; + expect(e.bpm, closeTo(128, 2.0)); + expect(e.confidence, greaterThan(0.7)); + }); + + test('pas assez d’onsets → null', () { + final tr = TempoTracker(minOnsets: 6); + tr.addOnset(0); + tr.addOnset(500); + expect(tr.estimate(), isNull); + }); + + test('onsets aléatoires → confiance basse', () { + final tr = TempoTracker(); + final rng = Random(3); + var t = 0; + for (var i = 0; i < 30; i++) { + t += 80 + rng.nextInt(400); // intervalles très irréguliers + tr.addOnset(t); + } + final e = tr.estimate(); + // Une estimation existe mais ne doit pas atteindre la confiance d'un + // train propre. + expect(e!.confidence, lessThan(0.85)); + }); + + test('fenêtre glissante : oublie les vieux onsets', () { + final tr = TempoTracker(); // fenêtre par défaut (6 s) + // Vieux onsets à 100 BPM, puis récents à 140 BPM bien après (> fenêtre). + for (final o in _clicks(bpm: 100, phaseMs: 0, count: 8)) { + tr.addOnset(o); + } + for (final o in _clicks(bpm: 140, phaseMs: 20000, count: 16)) { + tr.addOnset(o); + } + expect(tr.estimate()!.bpm, closeTo(140, 2.0)); + }); + }); +} diff --git a/rhythm_coach/windows/flutter/generated_plugin_registrant.cc b/rhythm_coach/windows/flutter/generated_plugin_registrant.cc index 6b1ff04..6cd7c7e 100644 --- a/rhythm_coach/windows/flutter/generated_plugin_registrant.cc +++ b/rhythm_coach/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterTtsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/rhythm_coach/windows/flutter/generated_plugins.cmake b/rhythm_coach/windows/flutter/generated_plugins.cmake index 9d5688b..6872753 100644 --- a/rhythm_coach/windows/flutter/generated_plugins.cmake +++ b/rhythm_coach/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_tts permission_handler_windows + record_windows share_plus url_launcher_windows )