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.
classAudioFilterapp/data/AudioFilter.kt
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): Doublereset()magnitudeAt(freqHz: Double): DoubleclassFFTProcessorcore/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): DoubleArraycomputeTTNR(magnitudesDbFS: DoubleArray): DoubleArrayobjectFilterChaincore/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 = {} ): ShortArrayclassFilterSpeccore/data/FilterChain.kt
A filter request without UI baggage — what the DSP chain needs plan 3.3.
classFilterTypecore/data/FilterType.kt
getDisplayName(): StringclassFrameResultcore/LiveAnalysisEngine.kt
processFrame(buffer: ShortArray): FrameResultreset()DoubleArray()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.
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, )update(sample: GnssSpeedSample): SampleRejection?predictAt(elapsedRealtimeNanos: Long): FloatestimateAt(elapsedRealtimeNanos: Long): SpeedEstimatereset()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?classConfigcore/data/KalmanSpeedEstimator.kt
Named, versionable parameters GPS-2.1; provisional until Gate GPS-5.
update(sample: GnssSpeedSample): SampleRejection?estimateAt(elapsedRealtimeNanos: Long): SpeedEstimatereset()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.
classEstimatorOutcomecore/data/SpeedEstimation.kt
What the estimator did with one fix — the per-fix trace payload GPS-0.4, GPS-2.2.
classEvalcore/data/SpeedReconstruction.kt
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.
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(): StringclassGnssSpeedSamplecore/data/SpeedEstimation.kt
One qualified GNSS speed measurement, as delivered by Android. GPS-3 extends this with signal diagnostics (satellites, C/N0, constellations).
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.
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.
classMeasurementcore/data/RtsSpeedSmoother.kt
One raw fix reduced to its measurement (z, R = σ²).
smooth( samples: List<GnssSpeedSample>, config: KalmanSpeedEstimator.Config = KalmanSpeedEstimator.Config(), ): List<SmoothedPoint>classResultcore/data/SpeedReconstruction.kt
reconstruct( samples: List<TelemetryData>, audioTimesNanos: List<Long>?, ): ResultobjectRtsSpeedSmoothercore/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.
classSampleRejectioncore/data/SpeedEstimation.kt
Why a sample did not update the estimator state normally GPS-06, GPS-12, GPS-13.
classSmoothedPointcore/data/RtsSpeedSmoother.kt
One smoothed state knot at a fix instant.
classSpeedEstimatecore/data/SpeedEstimation.kt
The estimator's answer at one instant — speed plus age, uncertainty and validity.
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?estimateAt(elapsedRealtimeNanos: Long): SpeedEstimatereset()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()currentTelemetry(): TelemetryDatatelemetryAt(elapsedRealtimeNanos: Long): TelemetryDatareset()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.
classSpeedSampleSourcecore/data/SpeedEstimation.kt
Which subscription produced a speed sample GPS-07.
classStatecore/data/RtsSpeedSmoother.kt
A v, a state with symmetric covariance (p11, p12, p22).
classStepcore/data/RtsSpeedSmoother.kt
Suivi d'ordres & cinématique
classEmergenceReportEntrycore/data/KinematicsData.kt
Entrée accumulée pour le rapport synthétique d'émergences.
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>reset()classKinematicsConfigcore/data/KinematicsData.kt
Serializable so the painstakingly-entered chain survives process death S1, plan 3.6.
parsedTargetOrders(): List<Double>calculateWheelRadiusMeters(): DoublegetEffectiveV1000(): DoublecalculateRpm(speedKmh: Float): DoublecalculateH1FreqHz(speedKmh: Float): DoubleclassKinematicsInputModecore/data/KinematicsData.kt
classManualOrderAnchorcore/data/KinematicsData.kt
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)sigmaOrderFreqHz( order: Double, sigmaSpeedKmh: Double, v1000Kmh: Double, ): Double = order * sigmaRpm(sigmaSpeedKmh, v1000Kmh)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).
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)yForFraction(v: Float): Float = top + (v * plotHeight * zoom + panYPx)fractionForX(x: Float): Float = if (plotWidth <= 0f || zoom <= 0f) 0f else (x - left - panXPx) / (plotWidth * zoom)fractionForY(y: Float): Float = if (plotHeight <= 0f || zoom <= 0f) 0f else (y - top - panYPx) / (plotHeight * zoom)visibleFractionX(): ClosedFloatingPointRange<Float> = fractionForX(left)..fractionForX(right)visibleFractionY(): ClosedFloatingPointRange<Float> = fractionForY(top)..fractionForY(bottom)containsPlot( x: Float, y: Float, )containsX(x: Float)containsY(y: Float)clampPan( candidateX: Float, candidateY: Float, ): Pair<Float, Float>zoomedAround( zoomChange: Float, focusX: Float, focusY: Float, panChangeX: Float = 0f, panChangeY: Float = 0f, ): PlotGeometryreset(): PlotGeometry = copy(zoom = 1f, panXPx = 0f, panYPx = 0f)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): FloatclassSmartTrackedOrderapp/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.
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): InttimeToIndex(posMs: Long, durationMs: Long, size: Int): IntclassTrackedHarmonicTagcore/data/KinematicsData.kt
Balise d'harmonique active avec timestamp de persistance pour rémanence visuelle.
classTrackedOrderLevelscore/OrderTrackingEngine.kt
Max level around one tracked order's target frequency.
searchTrackedOrder( absRow: FloatArray, ttnrRow: FloatArray, targetFreqHz: Double, df: Double, radiusBins: Int ): TrackedOrderLevelsclassWindowcore/data/OrderSearchPolicy.kt
The window the tracked-order search must use, or a suspension.
windowFor( sigmaFreqHz: Double?, h1FreqHz: Double, dfHz: Double, legacyRadiusBins: Int, ): WindowSession & 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.
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, )frameTimeNanos(frameIndex: Long): LongclassAudioSourceModecore/MeasurementSession.kt
classAudioTimestampSourcecore/CapturedAudioFrame.kt
How a frame's capture timestamp was obtained plan-gps GPS-1.2.
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.
classCursorStatecore/WavAnalysis.kt
cursorStateAt( posMs: Long, durationMs: Long, spectrogram: Spectrogram, telemetrySource: List<TelemetryData>, config: KinematicsConfig, ): CursorStateclassDisplayModecore/MeasurementSession.kt
classGpsStatuscore/Telemetry.kt
GPS quality for the UI LED — driven by SPEED accuracy where available audit G3.
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): (): () -> UnitregisterAnalysisResettable(resettable: () -> Unit): (): () -> UnitsetAudioSourceMode(mode: AudioSourceMode)forceMode(mode: AudioSourceMode)resetAnalysisState()clearEmergenceReport()clearStreams()setDisplayMode(mode: DisplayMode)toggleFreeze()setLatestTtnrSpectrum(spectrum: FloatArray)appendLiveFrame( magnitudes: FloatArray, ttnrSpectrum: FloatArray, retroUnmaskBins: List<Int>, retroRawRows: List<FloatArray>, maxHistory: Int, )setWavAnalysis( absList: List<FloatArray>, ttnrList: List<FloatArray>, )setTelemetryState(data: TelemetryData)setTelemetryHistory(history: List<TelemetryData>)appendLiveTelemetry( data: TelemetryData, maxHistory: Int, )setTrackedHarmonicTags(tags: List<TrackedHarmonicTag>)setEmergenceReportEntries(entries: List<EmergenceReportEntry>)setKinematicsConfig(config: KinematicsConfig)updateProvenance(transform: (AnalysisProvenance) -> AnalysisProvenance)setLoadedWavData(data: LoadedWavData?)updateDisplaySettings( newMinDb: Double, newMaxDb: Double, newMinFreq: Int, newMaxFreq: Int, newTimeWindowSec: Double, )setFftSize(size: Int)updateDetectorSettings( enabled: Boolean, thresholdDb: Double, magnitudeGateDb: Double, )postNotice(message: String?)dismissNotice()classOrderSweepResultcore/WavAnalysis.kt
orderSweep( spectrogram: Spectrogram, telemetry: List<TelemetryData>, config: KinematicsConfig, checkActive: () -> Unit = {}, ): OrderSweepResultclassSpectrogramcore/WavAnalysis.kt
dfHzAt(frameIdx: Int): Double = (sampleRateHz / 2.0)computeSpectrogram( pcm: ShortArray, sampleRate: Int, fftSize: Int, checkActive: () -> Unit = {}, ): Spectrogram?interpolateTheoreticalSpeed(telemetry: List<TelemetryData>): List<TelemetryData>classTelemetryDatacore/Telemetry.kt
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.
Capture, stockage & persistence
classAudioCaptureExceptionapp/AudioRepository.kt
Raised when the microphone cannot be opened or keeps failing C9 — surfaced to the UI, never a crash.
classAudioRepositoryapp/AudioRepository.kt
startAudioCapture(fftSize: Int = AudioConfig.DEFAULT_FFT_SIZE): Flow<CapturedAudioFrame>stopAudioCapture()classAudioTrackapp/data/VideoAudioExtractor.kt
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.
classDecodedTelemetrycore/data/TelemetryCodec.kt
Everything a deferred analysis needs from a sidecar GPS-4.4.
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)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)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)currentFile(): File? = dir?.let { File(it, CURRENT) }?.takeIf { it.exists() && it.length()sizeBytes(): Long = dir?.let { File(it, CURRENT).length() + File(it, PREVIOUS).length()clear()classDocumentV3core/data/TelemetryCodec.kt
classEncodeRequestcore/data/TelemetryCodec.kt
encodeV3( request: EncodeRequest, samples: List<TelemetryData>, audioTimesNanos: List<Long>, ): Stringdecode(jsonText: String?): List<TelemetryData> = decodeDocument(jsonText)decodeDocument(jsonText: String?): DecodedTelemetryclassErrorcore/data/LoadedWavData.kt
A file the app could not read at all.
classFailureapp/data/VideoAudioExtractor.kt
extractAudioFromVideoUri( context: Context, uri: Uri, onProgress: (Float) -> Unit = {}, ): ResultclassFieldLocationLoggerapp/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?, )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.
classFmtChunkapp/data/WavDataReader.kt
classLoadedWavDatacore/data/LoadedWavData.kt
The analyzed audio of a loaded WAV/video source, in its OWN sample rate audit C1.
classMetadatacore/data/FieldTraceV2.kt
`model=` is last on the header line: device models may contain spaces.
fichierNumberParsing.ktcore/data/NumberParsing.kt
String()classRecordcore/data/FieldTraceV2.kt
One raw fix + the estimator's outcome for it. Null = value absent.
classRecordingEntryapp/data/RecordingStore.kt
One saved measurement: WAV plus optional telemetry JSON sidecar.
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, )listRecordings(context: Context): List<RecordingEntry>readText( context: Context, uri: Uri, ): String?insertAndWrite( displayName: String, mime: String, write: (java.io.OutputStream) -> Unit, ): UriinterfaceResultapp/data/VideoAudioExtractor.kt
Typed outcome: the caller can say *why* an extraction produced nothing.
classSampleV3core/data/TelemetryCodec.kt
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
classSuccessapp/data/VideoAudioExtractor.kt
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.
classTracecore/data/FieldTraceV2.kt
encodeHeader(metadata: Metadata): StringencodeRow(r: Record): Stringparse(text: String): Trace?parseRow(line: String): Record?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.
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.
objectWavAudioWriterapp/data/WavAudioWriter.kt
writePcmToWav(pcmData: ShortArray, outputFile: File, sampleRate: Int)writePcmToStream(pcmData: ShortArray, out: java.io.OutputStream, sampleRate: Int)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, ): WavReadResultreadWavFromUri( context: Context, uri: Uri, jsonText: String? = null, ): WavReadResultclassWavReadErrorcore/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.
fichierWavReadMessages.ktapp/data/WavReadMessages.kt
WavReadError(): StringclassWavReadResultcore/data/LoadedWavData.kt
Typed outcome of a WAV import audit C2, plan 1.2.
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()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)fichierColor.ktapp/theme/Color.kt
nvhEmergenceColor(emergenceDb: Double): ColorfichierDiagnosticsSection.ktapp/ui/DiagnosticsSection.kt
DiagnosticsSection()classEmergencePeakapp/SpectrogramColormap.kt
Data class représentant un pic d'émergence tonale détecté sur la trame courante
getJetColorInt(v: Float): IntSpectrogramCanvas( history: List<FloatArray>, absHistory: List<FloatArray> = emptyList(), ttnrHistory: List<FloatArray> = emptyList()yForBin(binIndex: Float): FloatyForFreq(freqHz: Float): FloatxForFrame( 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): OffsetfichierEmergenceReportDialog.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)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)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, )LocationPermissionChip( coarseOnly: Boolean, onClick: () -> Unit, )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): NvhPermissionsrememberNvhPermissions(): NvhPermissionsPermissionGate( onMicrophoneUnavailable: () -> Unit, content: @Composable (NvhPermissions) -> Unit, )Context()classNvhViewModelFactoryapp/AppGraph.kt
Builds the session-sharing ViewModels plan 3.3.
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?setFilteredSource(file: File): Long? = prepare(file, null)restoreOriginalSource(): Long? = prepare(originalFile, originalUri)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(): PlotGeometryPlotDimens(): PlotGeometryrememberPlotDimens(): PlotDimensobjectPngExporterapp/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).
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, ): ReportStampclassReportViewModelapp/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, )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 ): BitmapappendLatest( latestFrame: FloatArray, minBin: Int, maxBin: Int, effectiveMin: Double, effectiveMax: Double, isTtnr: Boolean, maskBelowBin: Int = 0 ): BitmapcolumnsFor(frameCount: Int): Int = minOf(frameCount, MAX_COLUMNS).coerceAtLeast(1)colorFor(magnitude: Double, effectiveMin: Double, effectiveMax: Double, isTtnr: Boolean): IntclassTelemetryMetricapp/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()classVideoPlaybackStateapp/ui/VideoPlayerView.kt
Everything the transport row and the picture need, in one value.
fichierVideoPlayerView.ktapp/ui/VideoPlayerView.kt
VideoPlayerView( videoUri: Uri?, videoTitle: String, state: VideoPlaybackState, onSeekTo: (Long) -> Unit, onTogglePlayPause: () -> Unit, onOpenVideoSelection: () -> Unit, modifier: Modifier = Modifier, )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()togglePlayPause()stopAndRewind()resetPosition()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, )