Référence API

Chaque type et chaque fonction publique du projet — extraits directement du source Kotlin par site/tools/gen_reference.py (commité : la référence est régénérable et donc vérifiable). Les mentions entre crochets renvoient aux audits du dépôt.

DSP & analyse spectrale

objectAudioConfigcore/AudioConfig.kt

The single home of audio-format constants audit C1, plan 1.1. This is the ONLY file in app/src/main where a literal sample rate may appear — `ci/checks.sh` enforces it. Everything downstream of a loaded file must use that file's own `LoadedWavData.sampleRate`; only the live microphone pipeline uses LIVE_SAMPLE_RATE_HZ.

type de données (porteur d'état, sans méthode publique)
classAudioFilterapp/data/AudioFilter.kt
type de données (porteur d'état, sans méthode publique)
classBiQuadFiltercore/data/BiQuadFilter.kt

Filtre numérique IIR de type BiQuad. Implémentation basée sur les formules Audio EQ Cookbook de Robert Bristow-Johnson.

processSample(x: Double): Double
reset()
magnitudeAt(freqHz: Double): Double
Module |H(e^{jω})| à freqHz — pour vérification et tests D4.
classFFTProcessorcore/FFTProcessor.kt

FFT + tonal-emergence processing for ONE audio stream. Stateful (EMA integration, shock detector): create one instance per stream and never share it between live capture and file analysis audit D3. The sample rate is fixed per instance and threaded from the actual source audit C1 — never assume a rate here. plan 3.7 DSP core polish: - real FFT (DoubleFFT_1D.realForward) on preallocated, reused buffers — half the work and zero per-frame allocation of the old complexForward with zeroed imaginary parts D6. The returned magnitude array is REUSED across calls: copy it if you retain it. - No sentinel arithmetic: the TTNR scale is emergence dB with 0 = none; temporal integration runs on LINEAR power with the honest time constant TTNR_INTEGRATION_TAU_SEC (α is derived from the real frame interval, so integration time no longer changes with FFT size) D2. - The shock detector compares energy RISE RATE (dB/s), not per-call deltas, and the first frame of a stream is ANALYZED (the historical −120 initialization squelched it unconditionally) D3. - Sub-30 Hz masking is display policy and lives in the display layer (AudioConfig.DISPLAY_MIN_FREQ_HZ) — magnitudes here are true D7.

processFFT(audioData: ShortArray): DoubleArray
Calcule la FFT sur un bloc audio. RÉUTILISÉ à chaque appel — copier avant de le conserver.
computeTTNR(magnitudesDbFS: DoubleArray): DoubleArray
Spectre d'émergence tonale (heuristique NVH hybride — voir audit D1 : PAS une implémentation ECMA-74/ISO 1996-2 conforme).
objectFilterChaincore/data/FilterChain.kt

C10/D4, plan 2.5/3.3 Builds and runs the playback/analysis biquad chain. One rendered PCM feeds BOTH what the user hears and what the display analyzes.

buildBiquads(filters: List<FilterSpec>, sampleRateHz: Double): List<BiQuadFilter>
renderFilteredPcm( pcm: ShortArray, filters: List<FilterSpec>, sampleRateHz: Double, checkActive: () -> Unit = {} ): ShortArray
Run the chain over a whole PCM buffer. checkActive is invoked every 64k samples so a cancelled render stops mid-file L2.
classFilterSpeccore/data/FilterChain.kt

A filter request without UI baggage — what the DSP chain needs plan 3.3.

type de données (porteur d'état, sans méthode publique)
classFilterTypecore/data/FilterType.kt
getDisplayName(): String
classFrameResultcore/LiveAnalysisEngine.kt
processFrame(buffer: ShortArray): FrameResult
reset()
L7 Full state wipe on any source/config transition: shock detector, TTNR integration, persistence and retro buffer — ghost data from a previous session/config can no longer re-fire. (The companion order EMA is reset on its own OrderTrackingEngine.reset.)
DoubleArray()
P1, plan 3.5 Spectrum storage/display conversion — computation stays double inside the DSP.
classLiveAnalysisEnginecore/LiveAnalysisEngine.kt

C6, L3, plan 2.2 ALL mutable live-DSP state lives here — the ViewModel keeps no analysis fields. Methods are synchronized as a belt on top of the single-DSP-thread confinement (reset() may be invoked from the main thread on mode/config transitions L7). Behavior is a faithful extraction of the historical live path (persistence counters, 150 ms retro-unmask buffer, 0.75/0.25 EMA). Order-domain work (EMA blend, tag detection) lives in OrderTrackingEngine plan 3.2.

type de données (porteur d'état, sans méthode publique)

Chaîne GNSS / vitesse

classAlphaBetaSpeedEstimatorcore/data/AlphaBetaSpeedEstimator.kt

α-β tracker over GNSS Doppler speed audit G1–G4, plan 2.4, now behind the SpeedEstimator contract plan-gps GPS-0.2. Purpose: turn ~1 Hz Doppler fixes into a continuously *predictable* speed (v + a·dt) so the live order projection needs no lookahead — this is what deletes the old 1.2 s display latency L5. Acceleration falls out of the filter as a by-product, replacing the raw 1 Hz derivative whose wall-clock dt made it spike on clock adjustments G1. Units and time base GPS-0.5: speeds m/s, accelerations m/s²; every timestamp is BOOTTIME nanoseconds (elapsedRealtimeNanos) — never UTC. Pure Kotlin — no Android imports — so it is JVM-unit-testable. Gains: α=0.5 with β=α²/(2−α)≈0.17 (critically damped pairing for ~1 Hz updates). PROVISIONAL until tuned on the Phase 0.8 field drive logs. Known limitations, deliberately pinned by `SpeedEstimatorContractTest` until the GPS plan replaces them (audit-gps §4): - beyond maxPredictAheadSeconds the numeric output freezes at v + a·cap; estimateAt *describes* that as INVALID but nothing enforces it GPS-01; - the measurement σv is recorded, not weighted — a ±5 m/s fix corrects the state exactly like a ±0.05 m/s one GPS-02; - no covariance: estimates carry null sigmas GPS-04; - a second consecutive implausible fix is accepted without testing its coherence with the first GPS-06.

update( elapsedRealtimeNanos: Long, measuredMps: Float, )
Legacy entry point (σv unknown). Prefer update with a GnssSpeedSample.
update(sample: GnssSpeedSample): SampleRejection?
predictAt(elapsedRealtimeNanos: Long): Float
Speed prediction at elapsedRealtimeNanos — the per-FFT-frame read.
estimateAt(elapsedRealtimeNanos: Long): SpeedEstimate
GPS-0.1 Full estimate with age and DESCRIPTIVE validity. The validity thresholds are provisional (GPS-1.1 makes the horizon configurable and enforced; GPS-2 derives validity from covariance). Nothing consumes the validity yet — the frozen-number defect stays pinned GPS-01.
reset()
classConfigcore/data/GnssSpeedSession.kt

Named, testable thresholds plan-gps §2 — PROVISIONAL until Gate GPS-5 tuning.

update(sample: GnssSpeedSample): SampleRejection? = qualify(sample) ?: estimator.update(sample)
estimateAt(elapsedRealtimeNanos: Long): SpeedEstimate = estimator.estimateAt(elapsedRealtimeNanos)
reset() = estimator.reset()
kinematicSpeedMps(elapsedRealtimeNanos: Long): Float?
GPS-09 The only entry point for RPM/H1/order math. Null = no usable speed (INVALID) — callers must suspend kinematic tracking, not coast.
classConfigcore/data/KalmanSpeedEstimator.kt

Named, versionable parameters GPS-2.1; provisional until Gate GPS-5.

update(sample: GnssSpeedSample): SampleRejection?
estimateAt(elapsedRealtimeNanos: Long): SpeedEstimate
reset()
classEstimateValiditycore/data/SpeedEstimation.kt

Validity of a SpeedEstimate GPS-01, GPS-09. In GPS-0 this is DESCRIPTIVE only — nothing enforces it yet; GPS-1.1 makes it gate every kinematic use.

type de données (porteur d'état, sans méthode publique)
classEstimatorOutcomecore/data/SpeedEstimation.kt

What the estimator did with one fix — the per-fix trace payload GPS-0.4, GPS-2.2.

type de données (porteur d'état, sans méthode publique)
classEvalcore/data/SpeedReconstruction.kt
type de données (porteur d'état, sans méthode publique)
classGnssDiagnosticscore/data/GnssDiagnostics.kt

plan-gps GPS-3.3 Observable GNSS signal quality, snapshotted from `GnssStatus` at each satellite-status change. These values serve DIAGNOSIS and trace analysis (multipath, canyon, windshield attenuation…). They never replace `speedAccuracy` as the mathematical variance of a fix GPS-12 — the estimator does not read them.

type de données (porteur d'état, sans méthode publique)
classGnssDiagnosticsMonitorapp/GnssDiagnosticsMonitor.kt

plan-gps GPS-3.3/3.4/3.5 GNSS observability around the speed chain: - snapshots `GnssStatus` (satellites, C/N0, constellations, L5) into latest for the drive traces — diagnostics only, never a variance substitute GPS-12; - requests full-tracking measurements (API 31+) ONLY while registered and only when fullTrackingRequested — the A/B switch of the GPS-5 campaign; the callback is consumed minimally (event count proves the cadence); - builds the per-device capability matrix stamped into trace headers. Owned by SpeedProvider; registered/unregistered with the LIVE session.

onSatelliteStatusChanged(status: GnssStatus)
onGnssMeasurementsReceived(event: GnssMeasurementsEvent)
register()
unregister()
capabilitiesLine(): String
GPS-3.5 Capability matrix, stamped into every trace header.
classGnssSpeedSamplecore/data/SpeedEstimation.kt

One qualified GNSS speed measurement, as delivered by Android. GPS-3 extends this with signal diagnostics (satellites, C/N0, constellations).

type de données (porteur d'état, sans méthode publique)
classGnssSpeedSessioncore/data/GnssSpeedSession.kt

plan-gps GPS-1.1, GPS-1.3 The GNSS speed session: fix qualification, validity enforcement and session lifecycle around a SpeedEstimator. This is the single gate between Android's Location callbacks and the kinematic chain (RPM / H1 / order tracking): - every sample is qualified BEFORE it can touch the estimator — non-finite or negative speeds, mock fixes (unless allowed by Config.acceptMockFixes) and cached/backlogged fixes are rejected with a typed reason GPS-12, GPS-13; - kinematicSpeedMps is the ONLY speed the kinematic chain may consume GPS-09: it returns null — never a frozen number — once the estimate is INVALID (no fix, or beyond the prediction horizon) GPS-01; - reset starts a fresh session: callers MUST invoke it on every LIVE-mode entry/exit so no previous session's speed survives a restart GPS-08. The estimate's numeric fields remain populated even when INVALID — they are diagnostic values for traces and displays that show their own "--" state GPS-D4; computation goes through kinematicSpeedMps only. Units and time bases GPS-0.5: m/s, m/s², BOOTTIME nanoseconds. Horizontal position accuracy is deliberately absent here — it is never a mathematical substitute for speed accuracy GPS-1.3; the σv-less case is classified DEGRADED by the estimator instead. Pure Kotlin — no Android imports — JVM-unit-testable.

type de données (porteur d'état, sans méthode publique)
classKalmanSpeedEstimatorcore/data/KalmanSpeedEstimator.kt

plan-gps GPS-2 Linear Kalman filter over GNSS Doppler speed — the uncertainty-aware replacement for the fixed-gain α-β tracker (closes GPS-02, GPS-04, GPS-05, GPS-06, GPS-14 estimator-side). Model: ``` x = v, a F(dt) = [1, dt, 0, 1] z = Android GNSS speed H = 1, 0 R = max(σv, floor)² Q(dt) = q · [dt³/3, dt²/2, dt²/2, dt] (white-jerk PSD q) ``` - Double precision core; covariance kept symmetric via a Joseph-form update; variable dt GPS-2.1. - The declared σv weights every update (R = σv²); a fix without σv gets the conservative Config.defaultSigmaMps and marks estimates DEGRADED GPS-02, GPS-1.3. - Robust rejection GPS-2.2: the normalized innovation NIS = y²/S gates each fix. A rejected fix leaves the state at its last accepted epoch, so prediction uncertainty keeps growing normally. Reacquisition needs two MUTUALLY COHERENT rejected fixes (implied acceleration plausible), a rejection streak (safety valve), or a gap long enough for a full re-seed. - Stationary state with hysteresis GPS-2.3: near zero the published speed/acceleration are an honest 0 instead of estimation flicker; the internal state is never silently saturated (no clamp substitutes for validity). Units and time bases GPS-0.5: m/s, m/s², BOOTTIME nanoseconds. Pure Kotlin — JVM-unit-testable. All parameters in Config are PROVISIONAL until the Gate GPS-5 field-tuning campaign.

type de données (porteur d'état, sans méthode publique)
classMeasurementcore/data/RtsSpeedSmoother.kt

One raw fix reduced to its measurement (z, R = σ²).

smooth( samples: List<GnssSpeedSample>, config: KalmanSpeedEstimator.Config = KalmanSpeedEstimator.Config(), ): List<SmoothedPoint>
Smooth timestamped raw fixes. Returns knots at every ACCEPTED fix time, in input order; rejected/degenerate fixes produce no knot.
classResultcore/data/SpeedReconstruction.kt
reconstruct( samples: List<TelemetryData>, audioTimesNanos: List<Long>?, ): Result
Rebuild theoretical speeds (+σ) for samples. audioTimesNanos (v3 sidecars) gives each sample's audio BOOTTIME; without it, samples are evaluated at their own fix time.
objectRtsSpeedSmoothercore/data/RtsSpeedSmoother.kt

plan-gps GPS-4.4 Deferred speed reconstruction: a forward Kalman pass (same model and KalmanSpeedEstimator.Config parameters as the LIVE estimator) followed by a backward Rauch–Tung–Striebel pass. A recorded analysis has the FUTURE fixes too — the smoothed trajectory uses them, which the causal LIVE filter never may plan-gps §2. Statistically implausible fixes (NIS gate) are simply dropped offline; a gap longer than the re-seed threshold splits the trace into independently smoothed segments (no information crosses a re-seed boundary). Frame-by-frame EXTRAPOLATED speeds are never fed back in as truth — input is raw fixes only. Units and time bases GPS-0.5: m/s, m/s², BOOTTIME nanoseconds. Pure Kotlin — JVM-unit-testable.

type de données (porteur d'état, sans méthode publique)
classSampleRejectioncore/data/SpeedEstimation.kt

Why a sample did not update the estimator state normally GPS-06, GPS-12, GPS-13.

type de données (porteur d'état, sans méthode publique)
classSmoothedPointcore/data/RtsSpeedSmoother.kt

One smoothed state knot at a fix instant.

type de données (porteur d'état, sans méthode publique)
classSpeedEstimatecore/data/SpeedEstimation.kt

The estimator's answer at one instant — speed plus age, uncertainty and validity.

type de données (porteur d'état, sans méthode publique)
interfaceSpeedEstimatorcore/data/SpeedEstimation.kt

plan-gps GPS-0.2 The speed-estimation contract. GPS-2 swaps the α-β implementation for a Kalman filter behind this same interface.

update(sample: GnssSpeedSample): SampleRejection?
Feed one qualified GNSS sample. Returns null when the sample was accepted (including a re-seed after a long dropout), else why it was not.
estimateAt(elapsedRealtimeNanos: Long): SpeedEstimate
Evaluate the state at a BOOTTIME instant. GPS-1.2's target call is `estimateAt(audioFrame.centerTimeNanos)` — never "estimate now" GPS-03.
reset()
Cold start: forget fixes, state and validity (mode transitions — GPS-08).
classSpeedProviderapp/SpeedProvider.kt

GNSS speed acquisition audit §3b G1–G4, plan 2.4; plan-gps GPS-1/GPS-3: - GPS_PROVIDER is the ONLY metrological source G2, GPS-07: its listener is registered even while the provider is disabled (so enable/disable state changes arrive mid-session GPS-3.2); on API 31+ the subscription is an explicit high-accuracy, zero-interval, unbatched LocationRequest GPS-3.1. The fallback provider runs only while GPS is off and is INFORMATION_ONLY — its fixes never feed the estimator unless their provider field claims GNSS provenance. - All callbacks are delivered on the dedicated "nvh-gnss" thread, never on main GPS-3.1; delivery latency (callback − fix time) is in every trace row GPS-13. - The estimator behind GnssSpeedSession (Kalman since GPS-2) enforces qualification and validity; telemetryAt evaluates it at the audio capture instant GPS-03. - GnssDiagnosticsMonitor snapshots signal quality for traces and owns the full-tracking A/B switch and capability matrix GPS-3.3/3.4/3.5. - Runs only while started — LIVE mode only C7; start()/stop() reset the speed session GPS-08; stop() releases every GNSS resource. Units and time bases GPS-0.5: estimator I/O is m/s and BOOTTIME nanoseconds; TelemetryData speeds are km/h for display. Location.time (UTC) is logged for labeling only — it never enters interval math G1.

onLocationChanged(location: Location) = onFix(location, source = SpeedSampleSource.GPS)
onStatusChanged( provider: String?, status: Int, extras: Bundle?, )
onProviderEnabled(provider: String)
onProviderDisabled(provider: String)
start()
stop()
shutdown()
Full release (ViewModel death): stop + quit the GNSS thread L1.
currentTelemetry(): TelemetryData
Thread-safe snapshot with speed PREDICTED to now — for UI-driven reads.
telemetryAt(elapsedRealtimeNanos: Long): TelemetryData
GPS-03 Estimate evaluated at an explicit BOOTTIME instant — the DSP consumer passes the AUDIO CAPTURE time of the frame being analyzed, so a backlogged DSP queue can no longer pair a spectrum with a speed newer than the sound.
reset()
objectSpeedReconstructioncore/data/SpeedReconstruction.kt

plan-gps GPS-4.4 Speed reconstruction for DEFERRED analyses (WAV/video replays, recorded reports): RTS smoothing over the sidecar's RAW fixes, evaluated at each sample's audio instant. The recorder appends telemetry at frame rate (~43 fps) while GNSS fixes arrive at ~1 Hz, so runs of samples share one fix — the smoother sees each DISTINCT fix once, never the frame-rate copies, and never an already extrapolated per-frame speed (those must not be recycled as truth). Sidecars without usable monotonic fix times (v1, or synthetic imports) fall back to the historical corner interpolation; Result.statusLabel says which path produced the speeds — the label the report must print Gate GPS-4.

type de données (porteur d'état, sans méthode publique)
classSpeedSampleSourcecore/data/SpeedEstimation.kt

Which subscription produced a speed sample GPS-07.

type de données (porteur d'état, sans méthode publique)
classStatecore/data/RtsSpeedSmoother.kt

A v, a state with symmetric covariance (p11, p12, p22).

type de données (porteur d'état, sans méthode publique)
classStepcore/data/RtsSpeedSmoother.kt
type de données (porteur d'état, sans méthode publique)

Suivi d'ordres & cinématique

classEmergenceReportEntrycore/data/KinematicsData.kt

Entrée accumulée pour le rapport synthétique d'émergences.

type de données (porteur d'état, sans méthode publique)
classFramecore/OrderTrackingEngine.kt

One analysis frame, in the source's own bin grid (df = sampleRate / fftSize).

step( frame: Frame, nowMs: Long, holdMs: Long, targetOrders: List<Double>, activeTags: List<TrackedHarmonicTag>, report: MutableList<EmergenceReportEntry> ): List<TrackedHarmonicTag>
Process one frame: fold the TTNR spectrum into order domain, blend the EMA, detect emergent orders, merge them into report (mutated in place), and return the updated active-tag list (expired tags dropped, fresh detections overlaid). Detection only runs above MIN_SPEED_KMH / MIN_RPM; tag hold-time decay runs on every call.
reset()
L7 Ghost orders from a previous stream/config must never survive a transition.
classKinematicsConfigcore/data/KinematicsData.kt

Serializable so the painstakingly-entered chain survives process death S1, plan 3.6.

parsedTargetOrders(): List<Double>
Retourne la liste des ordres cibles renseignés par l'utilisateur (ex: 7.4, 18.0, 22.2, 36.0). Si la chaîne est vide, retourne une liste vide (mode détection ouverte).
calculateWheelRadiusMeters(): Double
Calcule le rayon dynamique sous charge de la roue (en mètres) à partir des dimensions pneu vendeur.
getEffectiveV1000(): Double
Calcule la V1000 équivalente en km/h pour 1000 RPM selon le mode de saisie sélectionné.
calculateRpm(speedKmh: Float): Double
Calcule le régime moteur (RPM) pour une vitesse donnée en km/h.
calculateH1FreqHz(speedKmh: Float): Double
Calcule la fréquence fondamentale H1 en Hz (RPM / 60).
classKinematicsInputModecore/data/KinematicsData.kt
type de données (porteur d'état, sans méthode publique)
classManualOrderAnchorcore/data/KinematicsData.kt
type de données (porteur d'état, sans méthode publique)
objectOrderSearchPolicycore/data/OrderSearchPolicy.kt

plan-gps GPS-4.1, GPS-4.2 Kinematic error budget and the dynamic tracked-order search window it implies (closes GPS-10). Error budget (speeds in km/h): ``` rpm = v · 1000 / V1000 σrpm = σv · 1000 / V1000 f(Hn) = n · rpm / 60 σf(Hn) = n · σrpm / 60 ``` The historical search radius was a FIXED ±1 bin (±Δf) while σf(H18) at the default V1000 and a 0.5 m/s speed σ is ≈ 54 Hz — the true line usually sat OUTSIDE the window and the tracker read noise at the projected bin. The window is now built from the actual uncertainty: k·σf + Δf, and BOUNDED — a window wider than the bound cannot pick one order's line apart from its neighbours', so tracking is SUSPENDED ("ordre non identifiable") instead of silently reporting an ambiguous value. V1000's own uncertainty (dynamic tire radius, gear-ratio rounding) is NOT modeled here — it can dominate the GNSS term and is characterized separately in the plan-5.3 validation document. When σ is UNKNOWN (α-β estimator, pre-v3 sidecars) the policy falls back to the historical fixed radius — legacy analyses keep their behavior and are marked "incertitude inconnue" by their DEGRADED validity instead. All constants PROVISIONAL until the Gate GPS-5 campaign.

sigmaRpm( sigmaSpeedKmh: Double, v1000Kmh: Double, ): Double = sigmaSpeedKmh * RPM_REFERENCE / v1000Kmh.coerceAtLeast(MIN_V1000_KMH)
σrpm from the speed σ, both the plan-gps GPS-4.1 formulas.
sigmaOrderFreqHz( order: Double, sigmaSpeedKmh: Double, v1000Kmh: Double, ): Double = order * sigmaRpm(sigmaSpeedKmh, v1000Kmh)
σ of order order's frequency, Hz.
classOrderTrackingEnginecore/OrderTrackingEngine.kt

THE order-tracking engine audit A2, D7, plan 3.2 — the single implementation of order-domain folding, EMA smoothing, harmonic-tag detection and emergence-report accumulation. The live pipeline and the WAV sweep both consume this class; the two historical ~130-line copies (which had already drifted) are gone. Stateful (order-domain EMA): one instance per stream — the ViewModel owns one for live capture, and each WAV sweep creates a fresh local one. Methods are synchronized as a belt on top of single-thread confinement (reset() may be invoked from the main thread on transitions L7).

type de données (porteur d'état, sans méthode publique)
classPlotGeometrycore/PlotGeometry.kt

The one description of where a plot's data area is, and how zoom/pan map data to pixels U3, U4, plan 4.2. Two audit findings share this object as their fix: - **U3** — margins and text sizes were raw pixel literals (`150f`, `60f`, `32f`), duplicated across two `pointerInput` blocks and the draw pass. On an xxxhdpi phone the axis gutter measured ~34 dp with ~9 sp text; on mdpi it was enormous. The caller now converts dp/sp ONCE via `Density` and hands the pixel values here, so touch handling and drawing cannot drift apart — they read the same object. - **U4** — pinch-zoom cropped the spectrogram bitmap, but the playhead, emergence beacons, harmonic tags and the H1 overlay were positioned in *unzoomed* plot coordinates, so every overlay detached from the image the moment the user zoomed. Overlays now place themselves through xForFraction/yForFraction, which apply the same transform the bitmap crop does, and containsPlot clips what falls outside the viewport. Coordinates: a *fraction* is a position in the DATA, 0..1 (u across time/frames, v down from the top of the frequency band). Pixels are canvas coordinates. Pure Kotlin: this is the geometry, not the rendering, so it is unit-testable.

xForFraction(u: Float): Float = left + (u * plotWidth * zoom + panXPx)
Data fraction (0..1 across the full data) -> canvas x.
yForFraction(v: Float): Float = top + (v * plotHeight * zoom + panYPx)
Data fraction (0 = top of the band, 1 = bottom) -> canvas y.
fractionForX(x: Float): Float = if (plotWidth <= 0f || zoom <= 0f) 0f else (x - left - panXPx) / (plotWidth * zoom)
Canvas x -> data fraction. Inverse of xForFraction.
fractionForY(y: Float): Float = if (plotHeight <= 0f || zoom <= 0f) 0f else (y - top - panYPx) / (plotHeight * zoom)
Canvas y -> data fraction. Inverse of yForFraction.
visibleFractionX(): ClosedFloatingPointRange<Float> = fractionForX(left)..fractionForX(right)
The data fractions currently visible horizontally — what the bitmap crop shows.
visibleFractionY(): ClosedFloatingPointRange<Float> = fractionForY(top)..fractionForY(bottom)
The data fractions currently visible vertically.
containsPlot( x: Float, y: Float, )
Is this canvas point inside the data area (not in the axis gutters)?
containsX(x: Float)
Is this canvas x inside the data area horizontally (for full-height overlays)?
containsY(y: Float)
Is this canvas y inside the data area vertically (for full-width overlays)?
clampPan( candidateX: Float, candidateY: Float, ): Pair<Float, Float>
Clamps a candidate pan so the viewport can never leave the data. At zoom 1 the only valid pan is 0: the plot exactly fills its area, and letting it slide would show blank space beside a measurement.
zoomedAround( zoomChange: Float, focusX: Float, focusY: Float, panChangeX: Float = 0f, panChangeY: Float = 0f, ): PlotGeometry
Zoom around a focal canvas point, keeping the data under that point in place. Returns the new geometry with the pan already clamped.
reset(): PlotGeometry = copy(zoom = 1f, panXPx = 0f, panYPx = 0f)
Back to the unzoomed, unpanned view.
objectSmartPathTrackercore/SmartPathTracker.kt

plan 3.3, audit §13.4 Assisted manual order tracing — pure and JVM-tested. From the user's anchor points, follows the spectral ridge between them: guide-line interpolation, local-max scoring with jump/guide penalties, sub-bin parabolic refinement, jump clamping, and a 5-point moving average.

compute(points: List<ManualOrderAnchor>, history: List<FloatArray>): List<ManualOrderAnchor>
expectedBinF(globalFrame: Int): Float
classSmartTrackedOrderapp/data/SmartTrackedOrder.kt

A manually-validated tracked order (report mode). Lives in :app, not :core, because its display color is a Compose type plan 3.1.

type de données (porteur d'état, sans méthode publique)
objectTimelineMappercore/data/TimelineMapper.kt

The single index/time mapping between parallel, uniformly-sampled timelines (FFT frames ↔ telemetry samples ↔ playback position) audit C17, plan 1.4. FFT-frame indices are NOT telemetry indices: a 5-minute WAV has ~12,900 frames but maybe 30 telemetry samples. Every cross-timeline lookup goes through these two functions — never index one list with another's index.

mapIndex(index: Int, fromSize: Int, toSize: Int): Int
Map an index between two lists that span the same time range. Exact identity when the sizes match (the live-mode 1:1 case).
timeToIndex(posMs: Long, durationMs: Long, size: Int): Int
Index of the entry at time posMs in a list of size entries spanning durationMs.
classTrackedHarmonicTagcore/data/KinematicsData.kt

Balise d'harmonique active avec timestamp de persistance pour rémanence visuelle.

type de données (porteur d'état, sans méthode publique)
classTrackedOrderLevelscore/OrderTrackingEngine.kt

Max level around one tracked order's target frequency.

searchTrackedOrder( absRow: FloatArray, ttnrRow: FloatArray, targetFreqHz: Double, df: Double, radiusBins: Int ): TrackedOrderLevels
Max |dBFS| / emergence in a ±radiusBins window around targetFreqHz. Center bin is ROUNDED (the historical sweep copy truncated — resolved deliberately to rounding, audit D7). D9, plan 3.7 The dBFS readout is SCALLOPING-CORRECTED: a parabola through the peak bin and its neighbors estimates the true tone amplitude, removing the ~1.4 dB ripple the raw bin max showed as an order swept across bin boundaries.
classWindowcore/data/OrderSearchPolicy.kt

The window the tracked-order search must use, or a suspension.

windowFor( sigmaFreqHz: Double?, h1FreqHz: Double, dfHz: Double, legacyRadiusBins: Int, ): Window
Build the search window from the tracked order's frequency σ (from sigmaOrderFreqHz) at fundamental h1FreqHz. sigmaFreqHz null = uncertainty unknown → the historical legacyRadiusBins window, always considered identifiable (legacy behavior, honestly labeled by the estimate's DEGRADED validity).

Session & analyse de fichiers

classAnalysisProvenancecore/MeasurementSession.kt

Where the numbers on screen (and in the exported report) came from plan 4.5, U7. Whoever knows a fact records it here: the live path knows which microphone route the platform actually granted, the analyzer knows the file and how its speeds were reconstructed. A report that prints measurements without saying how they were produced cannot be defended after the fact.

type de données (porteur d'état, sans méthode publique)
classAudioFrameClockcore/AudioFrameClock.kt

plan-gps GPS-1.2, GPS-03 Maps audio frame indices to BOOTTIME nanoseconds from a (framePosition, nanoTime) anchor — the relation `AudioRecord.getTimestamp` exposes. Guarantees monotonic non-decreasing output across calls even when a fresh anchor steps slightly backward (hardware timestamps jitter): a regression is clamped to the last returned value instead of ever going back in time. Units and time bases GPS-0.5: frame indices are sample frames since capture start; times are BOOTTIME nanoseconds. Pure Kotlin, single-threaded use (the capture loop owns it).

setAnchor( framePosition: Long, nanoTime: Long, )
Install or refresh the anchor: frame framePosition was captured at nanoTime.
frameTimeNanos(frameIndex: Long): Long
BOOTTIME of sample frame frameIndex, linear from the anchor at the nominal rate. Callers must query indices in capture order — the monotonic clamp is applied across successive calls.
classAudioSourceModecore/MeasurementSession.kt
type de données (porteur d'état, sans méthode publique)
classAudioTimestampSourcecore/CapturedAudioFrame.kt

How a frame's capture timestamp was obtained plan-gps GPS-1.2.

type de données (porteur d'état, sans méthode publique)
classCapturedAudioFramecore/CapturedAudioFrame.kt

plan-gps GPS-1.2, GPS-03 One analysis window with its CAPTURE time. The speed estimate for a spectrum must be evaluated at the BOOTTIME instant the sound was captured — `estimateAt(centerTimeNanos)` — never at the instant the DSP got around to processing it: a backlogged DSP queue used to silently pair a spectrum with a speed newer than the audio. Units and time bases GPS-0.5: times are BOOTTIME nanoseconds (elapsedRealtimeNanos base); pcm is 16-bit mono at sampleRateHz. Deliberately NOT a data class: pcm is a reused-content array and structural equality over it would be wrong and expensive.

type de données (porteur d'état, sans méthode publique)
classCursorStatecore/WavAnalysis.kt
cursorStateAt( posMs: Long, durationMs: Long, spectrogram: Spectrogram, telemetrySource: List<TelemetryData>, config: KinematicsConfig, ): CursorState
State displayed at playback position posMs: the FFT frame under the cursor and the (interpolated) telemetry with tracked-order levels (±1 bin — the cursor uses per-frame speeds) C17: all cross-timeline lookups via TimelineMapper.
classDisplayModecore/MeasurementSession.kt
type de données (porteur d'état, sans méthode publique)
classGpsStatuscore/Telemetry.kt

GPS quality for the UI LED — driven by SPEED accuracy where available audit G3.

type de données (porteur d'état, sans méthode publique)
classMeasurementSessioncore/MeasurementSession.kt

plan 3.3, audit A1/L7 The shared measurement-session state machine — the ONE holder of everything the three ViewModels (live, analyzer, report) measure and display together: source mode, spectral histories, telemetry, order tags/report, kinematics and display settings. Mode/config transitions run through here so the L7 contract has a single enforcement point: registered resettables (the live DSP engines, the analyzer's per-frame tag map) are wiped synchronously on every transition — no EMA or tag built under a previous config survives into the next one. Pure Kotlin: owners register hooks for their Android-side effects (mic enable, player release) instead of the session touching them.

registerModeTransitionHook(hook: (AudioSourceMode) -> Unit): (): () -> Unit
Called synchronously (registration order) on every setAudioSourceMode.
registerAnalysisResettable(resettable: () -> Unit): (): () -> Unit
Wiped by resetAnalysisState — engines and per-stream caches register here L7.
setAudioSourceMode(mode: AudioSourceMode)
forceMode(mode: AudioSourceMode)
Analyzer-only escape hatch: the video/WAV load paths historically set the mode WITHOUT the full transition wipe (they clear selectively around the load). Never use for user-driven mode switches.
resetAnalysisState()
L7 No ghost EMA/tags across any source or kinematics transition.
clearEmergenceReport()
clearStreams()
Clear the rolling spectral/telemetry streams (mode change, new load, FFT change).
setDisplayMode(mode: DisplayMode)
toggleFreeze()
setLatestTtnrSpectrum(spectrum: FloatArray)
appendLiveFrame( magnitudes: FloatArray, ttnrSpectrum: FloatArray, retroUnmaskBins: List<Int>, retroRawRows: List<FloatArray>, maxHistory: Int, )
Live-mode append. U9/U10 root fix, plan 3.4 Histories are CANONICAL CHRONOLOGICAL — newest LAST — in every mode; only the draw layer knows the live view scrolls right-to-left. Applies the 150 ms retro-unmask patch to the k most recent TTNR rows and ring-trims to maxHistory.
setWavAnalysis( absList: List<FloatArray>, ttnrList: List<FloatArray>, )
Analyzer-mode replace: the full-file sweep result.
setTelemetryState(data: TelemetryData)
setTelemetryHistory(history: List<TelemetryData>)
appendLiveTelemetry( data: TelemetryData, maxHistory: Int, )
Chronological, like the spectral histories plan 3.4.
setTrackedHarmonicTags(tags: List<TrackedHarmonicTag>)
setEmergenceReportEntries(entries: List<EmergenceReportEntry>)
setKinematicsConfig(config: KinematicsConfig)
Raw setter — callers own the resetAnalysisState + re-sweep choreography.
updateProvenance(transform: (AnalysisProvenance) -> AnalysisProvenance)
setLoadedWavData(data: LoadedWavData?)
updateDisplaySettings( newMinDb: Double, newMaxDb: Double, newMinFreq: Int, newMaxFreq: Int, newTimeWindowSec: Double, )
C14 The dynamic range stays valid: min is clamped ≥ 5 dB below max.
setFftSize(size: Int)
Raw setter — the live owner guards C13 and restarts capture around it.
updateDetectorSettings( enabled: Boolean, thresholdDb: Double, magnitudeGateDb: Double, )
postNotice(message: String?)
dismissNotice()
classOrderSweepResultcore/WavAnalysis.kt
orderSweep( spectrogram: Spectrogram, telemetry: List<TelemetryData>, config: KinematicsConfig, checkActive: () -> Unit = {}, ): OrderSweepResult
Full-file order sweep A2, plan 3.2/3.3: per-telemetry tracked-order levels (±3-bin window — interpolated speeds carry more error), then the frame-by-frame OrderTrackingEngine pass on a fresh engine instance. Cost is O(frames × ORDER_BINS): ~12,900 frames for a 5-minute file, each folding a full spectrum into the 1000-order grid. **Never call this on the main thread** V13.2 C-1. checkActive is invoked once per telemetry sample and once per frame so a cancelled coroutine stops the sweep instead of finishing work nobody will read.
classSpectrogramcore/WavAnalysis.kt
dfHzAt(frameIdx: Int): Double = (sampleRateHz / 2.0)
computeSpectrogram( pcm: ShortArray, sampleRate: Int, fftSize: Int, checkActive: () -> Unit = {}, ): Spectrogram?
Full-file STFT at 50 % overlap on a fresh FFTProcessor (stateful — never share with the live stream audit D3). Returns null when the PCM is shorter than one FFT frame. checkActive is invoked per frame so a cancelled coroutine stops the sweep.
interpolateTheoreticalSpeed(telemetry: List<TelemetryData>): List<TelemetryData>
When an imported telemetry track carries no theoretical speed, derive one by linear interpolation between the GPS speed's corner points (historical behavior of the load path).
classTelemetryDatacore/Telemetry.kt
type de données (porteur d'état, sans méthode publique)
objectWavAnalysiscore/WavAnalysis.kt

plan 3.3 The pure computation core of the WAV/video analyzer — extracted from the ViewModel so the full-file pipeline is JVM-testable: STFT sweep, theoretical-speed interpolation and the order-tracking sweep.

type de données (porteur d'état, sans méthode publique)

Capture, stockage & persistence

classAudioCaptureExceptionapp/AudioRepository.kt

Raised when the microphone cannot be opened or keeps failing C9 — surfaced to the UI, never a crash.

type de données (porteur d'état, sans méthode publique)
classAudioRepositoryapp/AudioRepository.kt
startAudioCapture(fftSize: Int = AudioConfig.DEFAULT_FFT_SIZE): Flow<CapturedAudioFrame>
plan-gps GPS-1.2 Emits CapturedAudioFrames carrying the BOOTTIME of each window's first and center sample, anchored on `AudioRecord.getTimestamp(TIMEBASE_BOOTTIME)` (refreshed periodically); when the hardware timestamp is unavailable the anchor falls back to the read-completion clock and frames are marked AudioTimestampSource.ESTIMATED (logged once). The speed chain evaluates its estimate at `centerTimeNanos` GPS-03.
stopAudioCapture()
classAudioTrackapp/data/VideoAudioExtractor.kt
type de données (porteur d'état, sans méthode publique)
classCaptureEngineapp/CaptureEngine.kt

C5, C7, plan 2.1 The ONE owner of live microphone capture. Settings and enablement changes flow through flatMapLatest: the previous capture is cancelled (mic released via AudioRepository.awaitClose) before a new one starts — the historical bug class where every settings change stacked another producer/consumer pair is structurally impossible here. Disabled (mode != LIVE, or user stop) means no capture flow at all: the mic indicator goes off. A capture error (mic busy, init failure) is reported via onCaptureError and completes only the inner flow — a later re-enable retries cleanly.

type de données (porteur d'état, sans méthode publique)
classDecodedTelemetrycore/data/TelemetryCodec.kt

Everything a deferred analysis needs from a sidecar GPS-4.4.

type de données (porteur d'état, sans méthode publique)
classDecodeStateapp/data/VideoAudioExtractor.kt

The decode loop's mutable state, split out so the loop body reads as two steps. Sample rate / channel count / PCM encoding start from the *input* track format and are corrected the moment the decoder announces its own output format — the two can differ, and believing the input one silently puts the whole analysis on the wrong frequency grid C1 class.

feedInput( codec: MediaCodec, extractor: MediaExtractor, containerDurationUs: Long, onProgress: (Float) -> Unit, )
drainOutput(codec: MediaCodec)
Drains one output buffer — and keeps being called AFTER the input EOS, which is exactly the tail the previous implementation threw away C12.
objectDiagnosticLogapp/data/DiagnosticLog.kt

A local, rotating diagnostic log V3, plan 4.7. The audit's error-handling census: 17 `catch` blocks, 10 ending in `printStackTrace()` and the rest silent; no logging framework, no crash reporting, and — for a field tool — no way for an operator to tell anyone *what* went wrong. A failed save, an unreadable WAV or a mic that would not open meant a lost test session with no explanation and nothing to send. Deliberate properties: - **Local only.** The app has no INTERNET permission and this must not create a reason to add one. Nothing is uploaded; the file leaves the device only through an explicit, user-initiated share. - **Bounded.** Two files of MAX_BYTES; the current one rotates onto the previous, which is discarded. A measurement instrument must not fill a phone with logs. - **Off the caller's thread.** A single writer thread, so logging from the DSP or capture path never blocks it C6. - **Never fatal.** Logging failures are swallowed after one Logcat warning: an error while reporting an error must not become the error.

init(context: Context)
Call once at process start; safe to repeat.
i( tag: String, message: String, ) = write("INFO ", tag, message, null)
w( tag: String, message: String, error: Throwable? = null, ) = write("WARN ", tag, message, error)
e( tag: String, message: String, error: Throwable? = null, ) = write("ERROR", tag, message, error)
notice(message: String) = write("NOTE ", "Notice", message, null)
The user-facing notices (banner messages) are logged verbatim, so a report matches.
currentFile(): File? = dir?.let { File(it, CURRENT) }?.takeIf { it.exists() && it.length()
The file to share, or null when nothing has been logged yet.
sizeBytes(): Long = dir?.let { File(it, CURRENT).length() + File(it, PREVIOUS).length()
Bytes currently held by both log files — shown next to the share action.
clear()
Both files, oldest first, for a share that should carry the full window.
classDocumentV3core/data/TelemetryCodec.kt
type de données (porteur d'état, sans méthode publique)
classEncodeRequestcore/data/TelemetryCodec.kt
encodeV3( request: EncodeRequest, samples: List<TelemetryData>, audioTimesNanos: List<Long>, ): String
Write a v3 sidecar. audioTimesNanos pairs 1:1 with samples (or empty).
decode(jsonText: String?): List<TelemetryData> = decodeDocument(jsonText)
Legacy-shaped entry point kept for existing import paths.
decodeDocument(jsonText: String?): DecodedTelemetry
Decode a sidecar of any schema. Returns empty samples on malformed input — telemetry is optional and must never block an audio import.
classErrorcore/data/LoadedWavData.kt

A file the app could not read at all.

type de données (porteur d'état, sans méthode publique)
classFailureapp/data/VideoAudioExtractor.kt
extractAudioFromVideoUri( context: Context, uri: Uri, onProgress: (Float) -> Unit = {}, ): Result
classFieldLocationLoggerapp/data/FieldLocationLogger.kt

Debug-build drive logger — AAA plan step 0.8, schema v2 per plan-gps GPS-0.4. Appends every raw GNSS/fused fix PLUS the estimator's outcome for it to a CSV under the app's external files dir (no storage permission needed; pull with `adb pull /sdcard/Android/data/<pkg>/files/field_logs`). The format is FieldTraceV2 — pure, round-trip-tested — and is the dataset the GPS-2 Kalman is tuned against GPS-13. The header carries an anonymized identity: a random per-install UUID plus the device model (needed for the GPS-5 device matrix; no serial, no account data). Time bases GPS-0.5: nanos columns are BOOTTIME; utcTimeMs is Location.time for human labeling only G1. Absent values are empty fields, never NaN. Must never affect the app: all I/O on its own single thread, all failures swallowed after one log line. Callers gate on BuildConfig.DEBUG.

log( location: Location, callbackTimeNanos: Long, isMock: Boolean, outcome: EstimatorOutcome, gnss: GnssDiagnostics?, )
GPS-0.4 One row per fix: raw Location fields + estimator state, validity, rejection reason and NIS at delivery time.
objectFieldTraceV2core/data/FieldTraceV2.kt

plan-gps GPS-0.4, GPS-13 Drive-trace schema v2 — the pure codec behind the debug FieldLocationLogger. Schema v1 recorded raw Location fields only and used NaN as its absence marker. v2 adds the callback delivery time, the estimator's outcome per fix (state, validity, rejection), and an anonymized device identity in the header — exactly the data the GPS-2 Kalman is tuned and validated against. Absence is an EMPTY CSV field, never a numeric sentinel (Gate GPS-0). Units and time bases GPS-0.5: `*Nanos` columns are BOOTTIME (elapsedRealtimeNanos); `utcTimeMs` is `Location.time`, kept for human labeling only — never for interval math audit G1. Speeds m/s, accelerations m/s², distances meters, bearings degrees.

type de données (porteur d'état, sans méthode publique)
classFmtChunkapp/data/WavDataReader.kt
type de données (porteur d'état, sans méthode publique)
classLoadedWavDatacore/data/LoadedWavData.kt

The analyzed audio of a loaded WAV/video source, in its OWN sample rate audit C1.

type de données (porteur d'état, sans méthode publique)
classMetadatacore/data/FieldTraceV2.kt

`model=` is last on the header line: device models may contain spaces.

type de données (porteur d'état, sans méthode publique)
fichierNumberParsing.ktcore/data/NumberParsing.kt
String()
C11, plan 1.8 Locale-tolerant numeric input: French keyboards produce comma decimals, which `toDoubleOrNull()` rejects — and silent fallbacks to defaults then corrupted every downstream RPM/H1/order computation.
classRecordcore/data/FieldTraceV2.kt

One raw fix + the estimator's outcome for it. Null = value absent.

type de données (porteur d'état, sans méthode publique)
classRecordingEntryapp/data/RecordingStore.kt

One saved measurement: WAV plus optional telemetry JSON sidecar.

type de données (porteur d'état, sans méthode publique)
objectRecordingStoreapp/data/RecordingStore.kt

Recording persistence audit C4/S3, plan 1.7. API 29+: MediaStore.Downloads under Download/NVH_Spectro_Exports/<name>/ — the supported path on scoped storage (the old direct-File writes failed silently on Android 10). Below API 29 the legacy public-directory path is used; failures there surface to the caller instead of losing data silently. All methods are blocking — call from Dispatchers.IO.

saveRecording( context: Context, baseName: String, pcm: ShortArray, sampleRate: Int, telemetryJson: String, )
Writes WAV + JSON. Throws IOException with a readable message on failure.
listRecordings(context: Context): List<RecordingEntry>
Newest-first list of saved recordings. Best-effort: returns what it can, never throws.
readText( context: Context, uri: Uri, ): String?
Reads a small text sidecar (telemetry JSON). Null on any failure.
insertAndWrite( displayName: String, mime: String, write: (java.io.OutputStream) -> Unit, ): Uri
interfaceResultapp/data/VideoAudioExtractor.kt

Typed outcome: the caller can say *why* an extraction produced nothing.

type de données (porteur d'état, sans méthode publique)
classSampleV3core/data/TelemetryCodec.kt
type de données (porteur d'état, sans méthode publique)
classSettingsapp/CaptureEngine.kt
setFftSize(fftSize: Int) = settings.update { it.copy(fftSize = fftSize)
setEnabled(enabled: Boolean) = settings.update { it.copy(enabled = enabled)
frames(): Flow<CapturedAudioFrame>
classSettingsStoreapp/data/SettingsStore.kt

S1, plan 3.6 Settings + kinematics survive process death. The historical app persisted NOTHING — an overnight OS kill silently discarded the whole test configuration, including the painstakingly-entered GMPe chain. Restore runs once at startup (before observers start, so defaults never clobber stored values); every later change is written back, debounced so slider drags do not hammer the disk.

restoreInto(session: MeasurementSession)
startObserving(session: MeasurementSession, scope: CoroutineScope)
classSuccesscore/data/LoadedWavData.kt
type de données (porteur d'état, sans méthode publique)
classSuccessapp/data/VideoAudioExtractor.kt
type de données (porteur d'état, sans méthode publique)
objectTelemetryCodeccore/data/TelemetryCodec.kt

S2, plan 3.6; plan-gps GPS-4.3 The telemetry sidecar format — kotlinx-serialization replaces the historical string-concatenation writer. Schema v2 added `schemaVersion`, `appVersion`, per-sample monotonic `elapsedRealtimeNanos`, `altitude` and `speedAccuracyMs`. Schema v3 makes the sidecar metrologically complete GPS-09/13/14 surfaces: per sample the ESTIMATED speed with its 1-σ and validity plus the paired audio-frame BOOTTIME; per document the estimator identity/parameters, the capture-time speed status ("causale" — deferred RTS smoothing happens at analysis, not capture GPS-4.4) and the order-search confidence level GPS-4.2. The reader decodes every version: v3 restores σ/validity; v1/v2 sidecars carry no uncertainty, so their estimates come back DEGRADED (usable, "incertitude inconnue") with σ = null — never 0-as-unknown.

type de données (porteur d'état, sans méthode publique)
classTracecore/data/FieldTraceV2.kt
encodeHeader(metadata: Metadata): String
encodeRow(r: Record): String
Locale-independent (Kotlin toString: '.' decimals); null → empty field.
parse(text: String): Trace?
Whole-file decode. Returns null only when the v2 header is missing.
parseRow(line: String): Record?
Malformed rows decode to null and are skipped — a trace can end mid-line on process death.
classUnsupportedcore/data/LoadedWavData.kt

A file the app can read but deliberately refuses to analyse (24-bit, float, 5.1…). detail is the offending value (bit depth, channel count) for the message.

type de données (porteur d'état, sans méthode publique)
objectVideoAudioExtractorapp/data/VideoAudioExtractor.kt

Decodes a video's audio track to mono 16-bit PCM for analysis C12, plan 4.8. Four defects the audit found in the previous implementation are fixed here: - the decode loop exited as soon as the *input* side queued EOS, dropping every output buffer still in flight — the last fraction of a second of audio was silently lost; - PCM accumulated in an `ArrayList<Short>`, i.e. ~13 M boxed objects for a 5-minute file (hundreds of MB of object overhead and GC storms on mid-range devices); - `INFO_OUTPUT_FORMAT_CHANGED` was ignored, so the decoder's *actual* sample rate, channel count and PCM encoding were assumed rather than read — a decoder that resamples produced a correct-looking spectrogram on the wrong frequency grid C1 class; - failures printed a stack trace and returned null, so the caller could not tell "no audio track" from "unsupported encoding". Progress is reported through onProgress (0..1) so the UI can show real progress instead of an indeterminate spinner for the many seconds a long video takes.

type de données (porteur d'état, sans méthode publique)
objectWavAudioWriterapp/data/WavAudioWriter.kt
writePcmToWav(pcmData: ShortArray, outputFile: File, sampleRate: Int)
Écrit un tableau d'échantillons PCM 16-bit Mono dans un fichier WAV standard, au sample rate fourni par l'appelant audit C1 — jamais de valeur par défaut.
writePcmToStream(pcmData: ShortArray, out: java.io.OutputStream, sampleRate: Int)
Stream variant for MediaStore targets plan 1.7.
objectWavDataReaderapp/data/WavDataReader.kt

Real RIFF parser audit C2: walks chunks (tolerates LIST/fact/bext/JUNK…), takes the audio format from the `fmt ` chunk instead of assuming a canonical 44-byte layout, downmixes stereo to mono, and rejects what it cannot decode honestly (non-PCM, non-16-bit) instead of producing spectral garbage. Buffers are sized from the actual data chunk — no blind max-size allocation.

readWavFile( file: File, jsonFile: File? = null, ): WavReadResult
readWavFromUri( context: Context, uri: Uri, jsonText: String? = null, ): WavReadResult
classWavReadErrorcore/data/LoadedWavData.kt

Why a WAV import failed audit C2, plan 1.2; §12, plan 4.4. The reader reports *what* happened; the UI decides how to say it. The messages used to be French literals built inside the RIFF walker, which made them impossible to localise and impossible to assert on without string matching.

type de données (porteur d'état, sans méthode publique)
fichierWavReadMessages.ktapp/data/WavReadMessages.kt
WavReadError(): String
Turns a typed WavReadError into the sentence an operator reads §12, plan 4.4. The reader stays free of user-facing text — it reports what happened — and every message lives in `strings.xml`, so the import failures a field user actually hits can be localised (and reviewed) without touching the RIFF walker.
classWavReadResultcore/data/LoadedWavData.kt

Typed outcome of a WAV import audit C2, plan 1.2.

type de données (porteur d'état, sans méthode publique)

UI, ViewModels & exports

classAnalyzerViewModelapp/AnalyzerViewModel.kt

plan 3.3 The analyzer third of the historical MainViewModel: WAV/video loading, the full-file sweep (computed in :core's WavAnalysis), playback ownership and the audio filter chain. Shared state lives in session.

updateKinematicsConfig(config: com.example.nvhspectro.data.KinematicsConfig)
updateSelectedTrackedOrder(order: Double)
addAudioFilter(filter: AudioFilter)
removeAudioFilter(filterId: String)
loadWavFromUri( context: Context, uri: Uri, jsonUri: Uri? = null, )
loadVideoFromUri( context: Context, uri: Uri, )
onCleared()
L1 Release path on ViewModel death.
objectAppGraphapp/AppGraph.kt

plan 3.3 Application-scoped object graph: the ONE MeasurementSession the three ViewModels share. Recreated only with the process.

startPersistence(application: Application)
S1, plan 3.6 Restore persisted settings/kinematics into the session, then write every later change back. Idempotent; restore completes before observation starts so defaults never clobber stored values.
fichierColor.ktapp/theme/Color.kt
nvhEmergenceColor(emergenceDb: Double): Color
Severity ramp for an emergence (TTNR) level, in dB above the local noise floor. One definition for every surface that grades emergence — the 2D telemetry graph, the canvas beacons, the emergence-report badges and the PDF U7. The thresholds are the detector's own reporting steps, not decoration: changing them changes what an operator is told is critical, so they live in exactly one place.
fichierDiagnosticsSection.ktapp/ui/DiagnosticsSection.kt
DiagnosticsSection()
The diagnostic-log surface V3, plan 4.7. Every notice the app shows an operator is also written to a local, size-bounded file. This is the only way it ever leaves the device: an explicit share, chosen by the user, to an app they pick. There is no network path — the app holds no INTERNET permission and this feature exists precisely so that none is needed to diagnose a field failure.
classEmergencePeakapp/SpectrogramColormap.kt

Data class représentant un pic d'émergence tonale détecté sur la trame courante

getJetColorInt(v: Float): Int
Retourne un Int ARGB basé sur la colormap "Jet"
SpectrogramCanvas( history: List<FloatArray>, absHistory: List<FloatArray> = emptyList(), ttnrHistory: List<FloatArray> = emptyList()
yForBin(binIndex: Float): Float
yForFreq(freqHz: Float): Float
xForFrame( frameIndex: Float, frameCount: Int, ): Float = geo.xForFraction(if (frameCount > 1) frameIndex / (frameCount - 1) else 0f)
getFreqY(freqHz: Float): Float = yForFreq(freqHz)
drawFilterBand( yTopRaw: Float, yBottomRaw: Float, )
mapAnchorToScreen(anchor: ManualOrderAnchor): Offset
fichierEmergenceReportDialog.ktapp/ui/EmergenceReportDialog.kt
EmergenceReportDialog( entries: List<EmergenceReportEntry>, kinematicsConfig: KinematicsConfig, onDismiss: () -> Unit, onClearReport: () -> Unit, )
EmergenceReportRow(entry: EmergenceReportEntry)
fichierExportDialog.ktapp/ui/ExportDialog.kt
ExportDialog( onDismiss: () -> Unit, telemetry: TelemetryData, onExport: (String, String) -> Unit, )
fichierInfoDialog.ktapp/ui/InfoDialog.kt
InfoDialog(onDismiss: () -> Unit)
InfoDetailRow( label: String, value: String, )
classInputapp/export/PngExporter.kt
export(application: Application, input: Input)
Renders and saves the export PNG. Call from a background dispatcher.
drawStackedGraph(title: String, unit: String, colorInt: Int, values: List<Double>)
fichierKinematicsDialog.ktapp/ui/KinematicsDialog.kt
KinematicsDialog( currentConfig: KinematicsConfig, onDismiss: () -> Unit, onSave: (KinematicsConfig) -> Unit, )
classLiveViewModelapp/LiveViewModel.kt

plan 3.3 The live-capture third of the historical MainViewModel: microphone pipeline (one consumer, DSP on the "nvh-dsp" thread C5, C6), GPS speed, the 30 s field recorder, and live display settings. All shared measurement state lives in session.

onPermissionsChanged() = applyResourcePolicy(session.audioSourceMode.value)
Re-applies the policy after a grant/revocation U1, plan 4.1.
classMainActivityapp/MainActivity.kt
onCreate(savedInstanceState: Bundle?)
fichierMainScreen.ktapp/MainScreen.kt
AppNavigation()
AppScreen( liveVm: LiveViewModel, analyzerVm: AnalyzerViewModel, reportVm: ReportViewModel, permissions: NvhPermissions, )
GpsLedIndicator(status: GpsStatus)
EmergenceReportButton( entryCount: Int, onClick: () -> Unit, )
Opens the harmonic Emergence Report A4, plan 4.6, decision D6. Sits in the GMPe banner because that is the context the report describes: it exists only while the kinematic chain is engaged, and its badge shows how many harmonics have been characterised so far.
LocationPermissionChip( coarseOnly: Boolean, onClick: () -> Unit, )
Replaces the GNSS LED when the app has no precise-location grant U1, plan 4.1. A red LED would claim "signal lost" for something that is not a signal problem at all; the operator needs to know it is a permission, and be able to act on it from here.
KpiItem( label: String, value: String, )
classNvhPermissionsapp/ui/Permissions.kt

What the app is actually allowed to do right now U1, plan 4.1. Each capability degrades on its own. The old flow demanded all three permissions and parked forever on "En attente des permissions…" if any was denied — with no rationale, no retry and no way to reach the system settings, so denying *location* (which a user who only wants a spectrogram will do) bricked the app until reinstall.

read(context: Context): NvhPermissions
rememberNvhPermissions(): NvhPermissions
Current grants, re-read every time the app comes back to the foreground. The resume refresh is what makes the "open the settings" path work: the user leaves for the system settings screen, flips a permission, and comes back to an app that already knows.
PermissionGate( onMicrophoneUnavailable: () -> Unit, content: @Composable (NvhPermissions) -> Unit, )
Asks once, then hands content the resulting capability set — blocking only on the one permission without which there is nothing to show at all. Microphone denied is NOT a dead end: the app still opens as a WAV/video analyzer, so a recorded session can be re-analysed on a phone whose mic the user will not grant.
Context()
Deep-link to this app's system settings page — the only route out of a permanent denial.
classNvhViewModelFactoryapp/AppGraph.kt

Builds the session-sharing ViewModels plan 3.3.

type de données (porteur d'état, sans méthode publique)
fichierOrderSelectionDialog.ktapp/ui/OrderSelectionDialog.kt
OrderSelectionDialog( currentOrder: Double, onOrderSelected: (Double) -> Unit, onDismiss: () -> Unit, )
objectPdfReportGeneratorapp/export/PdfReportGenerator.kt
ch(v: Float) = (v * 255f + 0.5f).toInt().coerceIn(0, 255)
generateReport()
drawColormapBox( canvas: Canvas, bitmap: Bitmap?, x: Float, y: Float, width: Float, height: Float, boxTitle: String, drawBrilliance: Boolean = false, )
classPlaybackControllerapp/PlaybackController.kt

L1, L2, L4, L6 — plan 2.3 The one owner of the playback MediaPlayer. - prepareAsync via a suspending API — no more synchronous prepare() on the main thread L4. - Original vs filtered sources are explicit (replaces the fragile "filtered_playback.wav" filename check) L2. - Every acquisition path releases the previous player; release() is idempotent and called from onCleared L1.

setOriginalSource(context: Context?, file: File?, uri: Uri?): Long?
Prepare a NEW original source (file or content uri). Suspends until the player is prepared; returns the media duration in ms, or null on failure.
setFilteredSource(file: File): Long? = prepare(file, null)
Swap in a filtered rendering of the same source; original refs are kept.
restoreOriginalSource(): Long? = prepare(originalFile, originalUri)
Back to the unfiltered original (when the filter chain empties).
play() = safe { mediaPlayer?.start()
pause()
seekTo(positionMs: Int) = safe { mediaPlayer?.seekTo(positionMs)
release()
classPlotDimensapp/ui/PlotDimens.kt

Every plot margin and canvas text size, in dp/sp, converted once U3, plan 4.2. The audit found these as raw pixel literals (`150f`, `60f`, `120f`, `40f`, `32f`, `26f`, `22f`) repeated in two `pointerInput` blocks and the draw pass of each canvas. Physical pixels mean the axis gutter is ~34 dp with ~9 sp text on an xxxhdpi phone and enormous on mdpi — very plausibly the "layout bugs found on device" class this project already hit — and duplicating them means touch handling can silently disagree with what is drawn. Canvas text also has to honour the user's font scale: `sp.toPx()` applies it, a raw pixel size ignores it entirely, which is why the old canvases were unreadable at large font scales while the rest of the UI grew §12.

PlotDimens(): PlotGeometry
The spectrogram's geometry for a given canvas size and view transform U3, U4. Both `pointerInput` blocks and the draw pass call this, so there is exactly one definition of where the plot is and how zoom/pan map data to pixels.
PlotDimens(): PlotGeometry
The telemetry graph's geometry (no zoom/pan: it always shows the whole window).
rememberPlotDimens(): PlotDimens
The plot metrics for the current density and font scale; recomputed only when those change.
objectPngExporterapp/export/PngExporter.kt

plan 3.3, C6-export PNG snapshot export of the frozen view — bitmap rendering on a background thread, MediaStore write on IO (the historical exportData built a 1400×1850 canvas plus a per-pixel spectrogram loop on the MAIN thread).

type de données (porteur d'état, sans méthode publique)
fichierReportModeScreen.ktapp/ui/ReportModeScreen.kt
ReportModeScreen( viewModel: ReportViewModel, onBack: () -> Unit, )
SegmentedToggleButton( options: List<String>, selectedIndex: Int, onOptionSelected: (Int) -> Unit, modifier: Modifier = Modifier, activeColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary, )
AutoResizedText( text: androidx.compose.ui.text.AnnotatedString, modifier: Modifier = Modifier, initialFontSize: androidx.compose.ui.unit.TextUnit = 11.sp, minFontSize: androidx.compose.ui.unit.TextUnit = 8.sp, )
classReportStampapp/export/ReportStamp.kt

The traceability block printed on every exported report U7, D1, plan 4.5, DEV-43. A customer-facing engineering deliverable that carries measurements but not *when*, *by which build*, *from which source* and *with which speed reconstruction* they were produced cannot be defended once it leaves the room. The audit found the PDF carried none of it. Everything here is a fact the app already knows; nothing is inferred.

formattedTimestamp(): String = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.FRANCE).format(generatedAt)
build( appVersion: String, generatedAt: Date, sourceMode: AudioSourceMode, provenance: AnalysisProvenance, sampleRateHz: Int, fftSize: Int, ): ReportStamp
classReportViewModelapp/ReportViewModel.kt

plan 3.3 The report third of the historical MainViewModel: report-mode snapshots, assisted manual order tracing (computed in :core's SmartPathTracker) and the PNG/PDF exports (rendered off the UI thread in the export/ package). Shared state lives in session.

toggleBrillanceMode()
toggleReportMode()
clearCurrentPoints()
selectValidatedOrder(order: SmartTrackedOrder?)
removeValidatedOrder(order: SmartTrackedOrder)
addManualTrackPoint( frameIndex: Int, binIndex: Int, )
validateCurrentOrder(customName: String? = null)
exportData( pedalPercent: String, comments: String, )
C6-export PNG snapshot rendered on Default, written via MediaStore.
savePdfToUri( context: Context, uri: Uri, )
fichierSaveRecordingDialog.ktapp/ui/SaveRecordingDialog.kt
SaveRecordingDialog( durationSec: Int, onSave: (String) -> Unit, onDismiss: () -> Unit, )
fichierSettingsDialog.ktapp/ui/SettingsDialog.kt
SettingsDialog( onDismiss: () -> Unit, sampleRateHz: Int, minDb: Double, maxDb: Double, onMinDbChange: (Double) -> Unit, onMaxDbChange: (Double) -> Unit, fftSize: Int, onFftSizeChange: (Int) -> Unit, minFreq: Int = 0, onMinFreqChange: (Int)
DspInfoRow( label: String, value: String, )
AddFilterDialog( existingCount: Int, onDismiss: () -> Unit, onAddFilter: (AudioFilter) -> Unit, )
classSpectrogramImageProducerapp/SpectrogramImageProducer.kt

P1, P2, U2 — plan 3.5 Owns the spectrogram pixel pipeline. - Full-file renders are DOWNSAMPLED to at most MAX_COLUMNS columns — a 5-minute file no longer allocates a ~13k-column bitmap (~52 MB) painted pixel-by-pixel on the main thread. - Rendering runs on a background dispatcher (the caller dispatches); the result is double-buffered so every update returns a DIFFERENT Bitmap instance — Compose repaints on data change, replacing the historical mutate-and-hope-for-recomposition hack (the "black until first interaction" quirk, U2). - One pixel buffer and two bitmaps for the producer's lifetime: zero per-frame allocation in the live path.

renderFull( history: List<FloatArray>, minBin: Int, maxBin: Int, effectiveMin: Double, effectiveMax: Double, isTtnr: Boolean, maskBelowBin: Int = 0 ): Bitmap
Full-file render (WAV/report): columns sample the frame list uniformly.
appendLatest( latestFrame: FloatArray, minBin: Int, maxBin: Int, effectiveMin: Double, effectiveMax: Double, isTtnr: Boolean, maskBelowBin: Int = 0 ): Bitmap
Live render: scroll one column left, paint the newest frame at the right edge.
columnsFor(frameCount: Int): Int = minOf(frameCount, MAX_COLUMNS).coerceAtLeast(1)
colorFor(magnitude: Double, effectiveMin: Double, effectiveMax: Double, isTtnr: Boolean): Int
classTelemetryMetricapp/ui/TelemetryGraph.kt
TelemetryGraph( history: List<TelemetryData>, metric: TelemetryMetric, timeWindowSec: Double, historySize: Int = 150, ttnrSpectrum: FloatArray = FloatArray(0)
fichierTheme.ktapp/theme/Theme.kt
NVHSpectroTheme(content: @Composable () -> Unit)
classTrackedOrderReadoutapp/LiveViewModel.kt
updateSettings( newMinDb: Double, newMaxDb: Double, newFftSize: Int, newMinFreq: Int, newMaxFreq: Int, newTimeWindow: Double, )
updateDetectorSettings( enabled: Boolean, thresholdDb: Double, magnitudeGateDb: Double, ) = session.updateDetectorSettings(enabled, thresholdDb, magnitudeGateDb)
selectMetric(metric: com.example.nvhspectro.ui.TelemetryMetric)
toggleH1Overlay()
setProjectedOrder(order: Double)
toggleAudioRecording()
cancelSaveAudioRecording()
saveAudioRecording(userCustomName: String)
onCleared()
L1 Every owned resource has a release path on ViewModel death.
classVideoPlaybackStateapp/ui/VideoPlayerView.kt

Everything the transport row and the picture need, in one value.

type de données (porteur d'état, sans méthode publique)
fichierVideoPlayerView.ktapp/ui/VideoPlayerView.kt
VideoPlayerView( videoUri: Uri?, videoTitle: String, state: VideoPlaybackState, onSeekTo: (Long) -> Unit, onTogglePlayPause: () -> Unit, onOpenVideoSelection: () -> Unit, modifier: Modifier = Modifier, )
Video mode's picture and transport U6, plan 4.8. The picture is always muted: the audio the analyst hears is the *analysed* PCM, played by `PlaybackController`, which is the single owner of the analysis clock. The video only ever follows it. The YouTube source was deleted with decision D7 — it loaded user-supplied URLs into a JavaScript-enabled WebView and analysed nothing at all V2.
classWavPlaybackCoordinatorapp/WavPlaybackCoordinator.kt

plan 3.3 Drives the analyzer's playback position: play/pause/seek state, the per-frame position poll that feeds the spectrum cursor, and the analyzed-end stop C3. The MediaPlayer itself is owned by PlaybackController; the loaded data lives in the session.

onSourceCompleted()
Wire to PlaybackController.onCompletion.
togglePlayPause()
stopAndRewind()
Pause and rewind to 0. The player stays prepared L6.
resetPosition()
Position bookkeeping only — used when a fresh source was just loaded.
seekTo(posMs: Long)
stepSeconds(offsetSec: Int)
fichierWavPlayerBar.ktapp/ui/WavPlayerBar.kt
WavPlayerBar( fileName: String, currentPosMs: Long, totalDurationMs: Long, isPlaying: Boolean, onPlayToggle: () -> Unit, onSeekTo: (Long) -> Unit, onStepSeconds: (Int) -> Unit, )
fichierWavSelectionDialog.ktapp/ui/WavSelectionDialog.kt
WavSelectionDialog( onDismiss: () -> Unit, onSelectEntry: (wavUri: Uri, jsonUri: Uri?) -> Unit, onImportExternal: () -> Unit, )
Recording picker plan 1.7: entries come from RecordingStore (MediaStore on API 29+, legacy folder fallback), listed off the main thread — the old version walked the filesystem inside composition.