From 07c1272cbfe20ef0da2facbc0f0db7af393b01ce Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sat, 30 May 2026 15:49:17 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(music):=20TempoTracker=20=E2=80=94=20e?= =?UTF-8?q?stimation=20BPM+phase=20par=20stats=20circulaires=20(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cœur DSP du micro (pur, testable) : onsets → BPM + phase + confiance via la longueur résultante des onsets projetés sur chaque période candidate. Plage BPM bornée pour limiter le repli d'octave. 6 tests (train propre, jitter, aléatoire, fenêtre glissante). --- rhythm_coach/lib/music/tempo_tracker.dart | 95 +++++++++++++++++++ .../test/music_tempo_tracker_test.dart | 89 +++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 rhythm_coach/lib/music/tempo_tracker.dart create mode 100644 rhythm_coach/test/music_tempo_tracker_test.dart 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/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)); + }); + }); +} From a8b3626838f5e3f5ee6b46b3764174f7c59fa735 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sat, 30 May 2026 15:51:15 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(music):=20OnsetDetector=20=E2=80=94=20?= =?UTF-8?q?onsets=20PCM=20par=20flux=20d'=C3=A9nergie=20+=20seuil=20adapta?= =?UTF-8?q?tif=20(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PCM mono → timestamps d'onsets (énergie par trame, flux demi-rectifié, seuil adaptatif EMA, période réfractaire). Streaming (trames incomplètes gardées). 4 tests sur click-tracks synthétiques (placement, silence, streaming, régularité). --- rhythm_coach/lib/music/onset_detector.dart | 71 +++++++++++++++++++ .../test/music_onset_detector_test.dart | 71 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 rhythm_coach/lib/music/onset_detector.dart create mode 100644 rhythm_coach/test/music_onset_detector_test.dart 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/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)); + } + }); + }); +} From 6c0d2700fec83cb41c28e5abbf58c690439e96df Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sat, 30 May 2026 15:56:20 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(music):=20MicTempoSource=20=E2=80=94?= =?UTF-8?q?=20c=C5=93ur=20feedPcm=20(intro/lock/d=C3=A9rive/gate)=20testab?= =?UTF-8?q?le=20(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BeatClock micro, cœur pur pilote par le PCM (horloge échantillons) : intro (écoute jusqu'au verrou confiant) → lecture (émet les battements, re-cale la dérive) ; gate(windowMs) pour ignorer nos bips. La capture micro réelle (record) sera une fine couche appelant feedPcm. 4 tests sur click-track (verrou, silence, gate, changement de tempo). Aucun changement de pubspec. --- rhythm_coach/lib/music/mic_tempo_source.dart | 98 +++++++++++++++++++ .../test/music_mic_tempo_source_test.dart | 87 ++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 rhythm_coach/lib/music/mic_tempo_source.dart create mode 100644 rhythm_coach/test/music_mic_tempo_source_test.dart 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/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(); + }); + }); +} From 3d53d77502becd0b05c9c88e566c3e927530ce38 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sat, 30 May 2026 16:03:54 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(music):=20int=C3=A9gration=20micro=20?= =?UTF-8?q?=E2=80=94=20MicCapture=20+=20source=20tap/micro=20+=20permissio?= =?UTF-8?q?ns=20(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Câblage plateforme du mode micro (hors ligne, capture locale) : - record ^5.2.1 (PCM16 stream, echoCancel + noiseSuppress). - MicCapture : record stream → samples mono −1..1 → MicTempoSource.feedPcm. decodePcm16 pur + testé. - MusicSessionController : flag useMic ; en micro lance la capture (permission), expose isCalibrating, gate(70ms) autour de chaque bip. - Écran : SegmentedButton tap/micro, état d'intro « écoute… », SnackBar si permission refusée. i18n fr/en/de/es. - Permission RECORD_AUDIO (Android manifest) + NSMicrophoneUsageDescription (iOS). 699 tests verts, analyze propre. À tester sur appareil réel avec de la musique. --- .../android/app/src/main/AndroidManifest.xml | 1 + rhythm_coach/ios/Runner/Info.plist | 2 + rhythm_coach/lib/l10n/app_de.arb | 5 + rhythm_coach/lib/l10n/app_en.arb | 5 + rhythm_coach/lib/l10n/app_es.arb | 5 + rhythm_coach/lib/l10n/app_fr.arb | 5 + rhythm_coach/lib/l10n/app_localizations.dart | 30 ++++ .../lib/l10n/app_localizations_de.dart | 15 ++ .../lib/l10n/app_localizations_en.dart | 15 ++ .../lib/l10n/app_localizations_es.dart | 15 ++ .../lib/l10n/app_localizations_fr.dart | 15 ++ rhythm_coach/lib/music/mic_capture.dart | 59 +++++++ .../lib/music/music_session_controller.dart | 84 ++++++--- .../lib/screens/music_mode_screen.dart | 164 +++++++++++++----- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + rhythm_coach/pubspec.lock | 56 ++++++ rhythm_coach/pubspec.yaml | 1 + rhythm_coach/test/music_mic_capture_test.dart | 27 +++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 22 files changed, 451 insertions(+), 64 deletions(-) create mode 100644 rhythm_coach/lib/music/mic_capture.dart create mode 100644 rhythm_coach/test/music_mic_capture_test.dart 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/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/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..ed5fb32 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_darwin 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")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) 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..b186c29 100644 --- a/rhythm_coach/pubspec.lock +++ b/rhythm_coach/pubspec.lock @@ -757,6 +757,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_darwin: + dependency: transitive + description: + name: record_darwin + sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + url: "https://pub.dev" + source: hosted + version: "0.7.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 +805,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..ce287b0 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: ^5.2.1 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/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 ) From 20ede1c54a0e0f61f1cacf3218ad9dd904a86f45 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Sat, 30 May 2026 16:11:23 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix(music):=20record=20^6.0.0=20(set=20f?= =?UTF-8?q?=C3=A9d=C3=A9r=C3=A9=20coh=C3=A9rent,=20le=20build=20casse=20su?= =?UTF-8?q?r=20record=205.x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record 5.2.1 contraignait record_linux <1.0.0 (0.7.2, sans startStream) tout en tirant platform_interface 1.6.0 → erreur de compilation (RecordLinux missing startStream) même pour un build Android. record 6.2.1 résout record_linux 1.3.1 (cohérent). API MicCapture inchangée. Build APK debug OK. --- .../Flutter/GeneratedPluginRegistrant.swift | 4 ++-- rhythm_coach/pubspec.lock | 24 ++++++++++++------- rhythm_coach/pubspec.yaml | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift b/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift index ed5fb32..7b54e82 100644 --- a/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/rhythm_coach/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,7 +11,7 @@ import file_selector_macos import flutter_local_notifications import flutter_tts import package_info_plus -import record_darwin +import record_macos import share_plus import shared_preferences_foundation import wakelock_plus @@ -23,7 +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")) - RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) + 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 b186c29..b384915 100644 --- a/rhythm_coach/pubspec.lock +++ b/rhythm_coach/pubspec.lock @@ -761,10 +761,10 @@ packages: dependency: "direct main" description: name: record - sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "6.2.1" record_android: dependency: transitive description: @@ -773,22 +773,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" - record_darwin: + record_ios: dependency: transitive description: - name: record_darwin - sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.1" record_linux: dependency: transitive description: name: record_linux - sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" url: "https://pub.dev" source: hosted - version: "0.7.2" + 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: diff --git a/rhythm_coach/pubspec.yaml b/rhythm_coach/pubspec.yaml index ce287b0..fa6a292 100644 --- a/rhythm_coach/pubspec.yaml +++ b/rhythm_coach/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: provider: ^6.1.2 wakelock_plus: ^1.6.1 audioplayers: ^6.1.0 - record: ^5.2.1 + record: ^6.0.0 shared_preferences: ^2.3.0 camera: ^0.12.0+1 google_mlkit_face_detection: ^0.13.0