Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rhythm_coach/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
Expand Down
2 changes: 2 additions & 0 deletions rhythm_coach/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Le mode Music écoute la musique pour se caler sur son tempo. L'audio reste sur l'appareil.</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
Expand Down
5 changes: 5 additions & 0 deletions rhythm_coach/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions rhythm_coach/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions rhythm_coach/lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions rhythm_coach/lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
30 changes: 30 additions & 0 deletions rhythm_coach/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions rhythm_coach/lib/l10n/app_localizations_de.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
15 changes: 15 additions & 0 deletions rhythm_coach/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
15 changes: 15 additions & 0 deletions rhythm_coach/lib/l10n/app_localizations_es.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
15 changes: 15 additions & 0 deletions rhythm_coach/lib/l10n/app_localizations_fr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
59 changes: 59 additions & 0 deletions rhythm_coach/lib/music/mic_capture.dart
Original file line number Diff line number Diff line change
@@ -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<Uint8List>? _sub;

MicCapture({this.sampleRate = 44100});

Future<bool> 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<bool> start(void Function(List<double> 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<double> decodePcm16(Uint8List bytes) {
final n = bytes.length ~/ 2;
final out = List<double>.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<void> stop() async {
await _sub?.cancel();
_sub = null;
if (await _rec.isRecording()) await _rec.stop();
}

Future<void> dispose() async {
await stop();
await _rec.dispose();
}
}
98 changes: 98 additions & 0 deletions rhythm_coach/lib/music/mic_tempo_source.dart
Original file line number Diff line number Diff line change
@@ -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<BeatTick> _ctrl =
StreamController<BeatTick>.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<BeatTick> 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<double> 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();
}
Loading
Loading