mahbot 0.4.2

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
//! Local audio transcription using Qwen3-ASR via the `qwen-asr` crate.
//!
//! # Architecture
//!
//! This module is the **only** audio transcription path — the previous
//! API-based transcriber has been removed.  When the model is
//! available, inference runs fully locally with Qwen3-ASR-0.6B.  The model
//! (~1.88 GB BF16 safetensors) is downloaded on first boot to
//! `~/.mahbot/models/qwen3-asr-0.6b/` (in the background — see below) and
//! memory-mapped by the `qwen-asr` crate — zero-copy weight loading with
//! minimal RSS overhead.
//!
//! # Boot strategy (mahbot-1709)
//!
//! The boot path never waits for the model: [`spawn_background_init_if_enabled`]
//! kicks the load-or-download chain off as a background task after the config
//! store is open (so `audio_transcription_use_local` is authoritative), and the
//! app + background services start regardless. A voice message that arrives
//! before the background load completes gets the icon-only annotation (the
//! load typically finishes within the first seconds of boot — the ~4 s model
//! load runs concurrently with the rest of startup).
//!
//! # Download strategy
//!
//! Files are SHA256-verified **once, at download time** (the streaming
//! `download_verified` hasher) — there is no per-boot re-verification of the
//! ~1.9 GB model (that was ~4.4 s of the awaited boot path). A silently
//! corrupted cache is therefore caught at load time instead: `QwenCtx::load`
//! returns `None` and the init falls through to the download+verify recovery
//! loop. Note the load is mmap-based, so partial corruption can survive the
//! load and surface as a SIGBUS at inference rather than a graceful fallback —
//! accepted tradeoff (ticket decision 1). Missing/corrupt files trigger the
//! background retry loop with exponential backoff.
//!
//! # Audio format conversion
//!
//! The qwen-asr crate expects raw f32 PCM samples at 16 kHz mono. Telegram
//! delivers voice messages as OGG (Opus) and audio files as MP3 (among other
//! formats). This module decodes these formats into 16 kHz mono f32 samples
//! before passing them to qwen-asr's `transcribe_audio()`:
//!
//! * **OGG/Opus** — decoded via the `ogg` crate (OGG demuxer) and `opus-decoder`
//!   crate (pure-Rust Opus decoder, no C dependencies).
//! * **MP3** — decoded via the `minimp3` crate (Rust wrapper wrapping a C library
//!   via `minimp3-sys`; requires a C compiler at build time).
//! * **WAV** — decoded via `qwen_asr::audio::parse_wav_buffer()` (qwen-asr's
//!   built-in parser, which handles resampling from any sample rate).
//!
//! # Call site
//!
//! The [`transcribe_file_async`] function is called from
//! [`crate::channels::enrichment::transcribe_audio_marker`].  When the local
//! model is unavailable or disabled in config, the caller annotates the
//! message with just the audio-transcription icon combo — there is no API
//! fallback.

use crate::audio::models_subdir;
use crate::util::UnwrapPoison;
use crate::util::model_state::{AtomicModelState, ModelLoadGuard, ModelState};
use anyhow::{Context, Result};
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, info, warn};

// ── Model metadata ────────────────────────────────────────────────────

/// HuggingFace repo for the Qwen3-ASR 0.6B model.
const MODEL_REPO: &str = "Qwen/Qwen3-ASR-0.6B";

/// Local subdirectory under `~/.mahbot/models/` where the model is stored.
pub(crate) const MODEL_DIR_NAME: &str = "qwen3-asr-0.6b";

/// Filenames required by `qwen-asr`.
///
/// The qwen-asr crate's `QwenCtx::load()` expects `model*.safetensors` and
/// `vocab.json` in the model directory. We also download `merges.txt` for the
/// BPE tokenizer.
pub(crate) const MODEL_FILENAME: &str = "model.safetensors";
pub(crate) const VOCAB_FILENAME: &str = "vocab.json";
pub(crate) const MERGES_FILENAME: &str = "merges.txt";

/// SHA256 checksums for download integrity verification.
///
/// Obtained from the HuggingFace repository metadata and verified at download
/// time. If these drift (HF re-uploads), users will see a SHA256 mismatch error
/// and the model will be re-downloaded.
///
/// # Updating checksums
///
/// To update these constants (e.g., when the upstream model version changes):
///
/// 1. Download the new model files manually (or let the automatic download
///    complete after updating the URLs).
/// 2. Compute the SHA256 of each file:
///    ```sh
///    shasum -a 256 ~/.mahbot/models/qwen3-asr-0.6b/model.safetensors
///    shasum -a 256 ~/.mahbot/models/qwen3-asr-0.6b/vocab.json
///    shasum -a 256 ~/.mahbot/models/qwen3-asr-0.6b/merges.txt
///    ```
/// 3. Replace the corresponding `*_SHA256` constants below with the new hashes.
/// 4. If the filenames changed, also update [`MODEL_FILENAME`], [`VOCAB_FILENAME`],
///    [`MERGES_FILENAME`], and the download URLs in [`download_file`].
const MODEL_SHA256: &str = "79d6cbd4c98c7bbffe9db2edac07f56cd6637d0d5944b27f6c2b8353840323ea";
const VOCAB_SHA256: &str = "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910";
const MERGES_SHA256: &str = "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5";

/// Default inference timeout (10 minutes).
///
/// Qwen3-ASR-0.6B inference on short audio clips (< 30s) completes in
/// seconds.  Longer recordings (e.g. voice memos, meetings) may take
/// several minutes.  This timeout prevents a hung inference from
/// permanently occupying a tokio blocking thread.
///
/// Shared by the voice pipeline and the audio-enrichment path.
pub(crate) const INFERENCE_TIMEOUT: Duration = Duration::from_mins(10);

/// Download timeout for the 1.88 GB model file (30 minutes).
/// The smaller files (vocab.json, merges.txt) complete far sooner under this
/// timeout because the stream is shared.
const MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(30);

/// Download timeout for config/vocabulary files (< 10 MB).
const SMALL_FILE_TIMEOUT: Duration = Duration::from_mins(1);

/// Retry sleep base: 5 seconds, doubled per attempt.
const DOWNLOAD_RETRY_BASE_SECS: u64 = 5;

/// Maximum number of download retry attempts.
const MAX_DOWNLOAD_RETRIES: u32 = 12;

// ── State machine ─────────────────────────────────────────────────────

/// Global singleton handle — `None` until loaded.
static GLOBAL_TRANSCRIBER: Mutex<Option<QwenLocalTranscriber>> = Mutex::new(None);

/// Lock-free-ish shared-model slot for the wake-word pipeline.
///
/// Populated by [`set_transcriber_ready`] alongside [`GLOBAL_TRANSCRIBER`].
/// The wake-word path reads this slot instead of locking the transcription
/// context mutex — [`transcribe_file_async`] holds the inner `QwenCtx` mutex
/// for the FULL duration of an inference (up to [`INFERENCE_TIMEOUT`]), so a
/// per-stride `shared_model_arc()` that locked the ctx would stall the whole
/// voice pipeline for the length of any concurrent transcription (e.g.
/// Telegram voice-message enrichment).  Cloning an `Arc` under this short
/// mutex is O(1) and uncontended (single writer on transcriber swap).
static SHARED_MODEL: Mutex<Option<Arc<qwen_asr::context::QwenModel>>> = Mutex::new(None);

/// Atomic state tracker to coordinate lazy initialization.
///
/// Model-loading lifecycle for the transcriber (Uninit → Loading → Ready /
/// Failed).  Shared wrapper extracted in
/// [`crate::util::model_state::ModelState`].  Failed is terminal for
/// transcription (the [`transcribe_file_async`] error path leaves the caller
/// with an icon-only audio annotation); the voice pipeline's bounded
/// auto-retry and the explicit GUI retry
/// ([`VoiceCommand::RetryModelLoading`]) are the only recovery knobs.
static STATE: AtomicModelState = AtomicModelState::new(ModelState::Uninit);

/// Atomically store a ready transcriber and transition state to [`ModelState::Ready`].
fn set_transcriber_ready(tc: QwenLocalTranscriber) {
    // Populate the shared-model slot BEFORE publishing the transcriber so the
    // wake-word path can never observe Ready without a model.  The ctx mutex
    // is uncontended here: the transcriber has just been loaded and has not
    // been published to GLOBAL_TRANSCRIBER yet, so no inference can be
    // running on it.
    *SHARED_MODEL.lock().unwrap_poison() = Some(tc.model_arc());
    *GLOBAL_TRANSCRIBER.lock().unwrap_poison() = Some(tc);
    STATE.store(ModelState::Ready, Ordering::Release);
}

/// The Qwen3-ASR local transcriber.
///
/// Wraps a `qwen-asr` inference context behind a high-level `transcribe_file`
/// method that handles audio format decoding, resampling, and inference.
///
/// # Thread safety
///
/// The inner `QwenCtx` is not `Sync` (it contains a `Box<dyn Fn + Send>`
/// token callback), so the inner context is wrapped in a `Mutex` for interior
/// mutability. The `Arc` allows the global singleton's outer lock to be
/// released immediately after cloning the handle, preventing lock contention
/// with background download completion ([`set_transcriber_ready`]).
pub struct QwenLocalTranscriber {
    ctx: Arc<Mutex<qwen_asr::context::QwenCtx>>,
}

impl QwenLocalTranscriber {
    /// Load the model from an explicit directory path.
    ///
    /// Unlike [`try_init_from_cache`](super::try_init_from_cache), this does
    /// not resolve the directory via [`crate::audio::models_subdir`] and
    /// therefore does not depend on the CONFIG storage root being set.
    fn try_load_from(dir: &Path) -> Option<Self> {
        let dir_str = dir.to_string_lossy().to_string();
        let mut ctx = qwen_asr::context::QwenCtx::load(&dir_str)?;
        ctx.want_language_detection = true;
        // Split audio longer than ~30 s at low-energy boundaries. Without
        // segmentation a single segment caps at 2048 decoded tokens, silently
        // truncating long recordings (voice pipeline allows up to 10 minutes).
        ctx.segment_sec = 30.0;
        Some(Self {
            ctx: Arc::new(Mutex::new(ctx)),
        })
    }

    /// Clone the inner `Arc` so callers can release the outer
    /// [`GLOBAL_TRANSCRIBER`] lock before running inference.
    fn clone_arc(&self) -> Arc<Mutex<qwen_asr::context::QwenCtx>> {
        Arc::clone(&self.ctx)
    }

    /// Clone the shared immutable [`qwen_asr::context::QwenModel`] Arc held by
    /// this transcriber's context.
    ///
    /// Called once by [`set_transcriber_ready`] to populate the lock-free
    /// [`SHARED_MODEL`] slot the wake-word pipeline reads — it is NOT called
    /// per scoring stride (that would lock the transcription context mutex,
    /// which `transcribe_file_async` holds for the full duration of an
    /// inference).  The encoder weights are read-only after load, so
    /// concurrent `Encoder::forward` calls with per-call scratch buffers are
    /// safe; the wake-word path passes `None` for `enc_bufs` (fresh buffers)
    /// rather than sharing the context-owned `EncoderBuffers`.
    fn model_arc(&self) -> Arc<qwen_asr::context::QwenModel> {
        Arc::clone(&self.ctx.lock().unwrap_poison().model)
    }
}

/// Asynchronously transcribe an audio file on a blocking thread.
///
/// Decodes the audio file and runs Qwen3-ASR inference on a dedicated
/// blocking thread via [`tokio::task::spawn_blocking`], preventing the
/// CPU-heavy work from stalling the async runtime.
///
/// # Lock scoping
///
/// The outer [`GLOBAL_TRANSCRIBER`] lock is held only long enough to clone the
/// inner [`Arc<Mutex<QwenCtx>>`] handle, then released before inference begins.
/// This prevents lock contention with [`set_transcriber_ready`] — if a
/// background download completes mid-transcription, the new transcriber can be
/// swapped in without waiting for inference to finish.
///
/// # Timeout
///
/// `inference_timeout` controls the maximum wall-clock time for the ONNX
/// inference step (audio decoding is excluded from the timeout).  Both
/// callers — the voice pipeline (recordings up to 10 minutes) and the
/// enrichment path for attached audio — pass [`INFERENCE_TIMEOUT`].
///
/// A shutdown guard races against the inference so that a stalled model
/// cannot block pipeline exit.
///
/// Returns an error if the local model is unavailable; the caller annotates
/// the message with just the audio-transcription icon combo on failure.
pub async fn transcribe_file_async(path: &Path, inference_timeout: Duration) -> Result<String> {
    let owned = path.to_owned();

    // Step 1: Decode audio on a blocking thread.
    let samples = tokio::task::spawn_blocking(move || decode_audio_to_mono_f32(&owned))
        .await
        .context("Audio decode task panicked")?
        .context("Failed to decode audio file to 16 kHz mono PCM")?;

    if samples.is_empty() {
        anyhow::bail!("Audio file is empty after decoding");
    }

    // Step 2: Clone the inference handle while holding the outer lock,
    // then release the outer lock before running inference.
    // std::sync::Mutex is fine here — lock hold time is nanoseconds
    // (clone Arc + check Option), and contention is rare (only during
    // background download completion).
    let ctx_arc = {
        let guard = GLOBAL_TRANSCRIBER.lock().unwrap_poison();
        let tc = guard.as_ref().ok_or_else(|| {
            anyhow::anyhow!("Local transcriber not available during async transcription")
        })?;
        tc.clone_arc()
    };

    // Step 3: Run inference with a timeout — the outer lock was released
    // after the clone.  The timeout prevents a hung inference from
    // permanently occupying a tokio blocking thread.  A shutdown guard
    // ensures the inference cannot block pipeline exit.
    let ctx_arc2 = ctx_arc;
    let shutdown_token = crate::shutdown::shutdown_token();
    let text = tokio::select! {
        result = tokio::time::timeout(inference_timeout, async move {
            tokio::task::spawn_blocking(move || {
                let mut ctx = ctx_arc2.lock().unwrap_poison();
                qwen_asr::transcribe::transcribe_audio(&mut ctx, &samples)
                    .ok_or_else(|| anyhow::anyhow!("Qwen3-ASR inference returned no output"))
            })
            .await
            .context("Inference task panicked")?
        }) => {
            result.context("Qwen3-ASR inference timed out")?
        }
        () = shutdown_token.cancelled() => {
            anyhow::bail!("Shutdown during audio transcription");
        }
    };

    text
}

// ── Audio decoding ────────────────────────────────────────────────────

/// Decode any supported audio file to 16 kHz mono f32 samples.
///
/// Supports WAV (directly via qwen-asr's parser for maximum compatibility),
/// OGG/Opus (Telegram voice messages), MP3, and raw audio.
///
/// `pub(crate)`: the voice-tests wake_word bench's real-audio
/// FAPH phase reuses this decoder for the pinned corpus (WAV/OGG/MP3 only —
/// the corpus format pin), so the bench binary needs no new decoder crates.
pub(crate) fn decode_audio_to_mono_f32(path: &Path) -> Result<Vec<f32>> {
    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();

    // Read the file into memory for decoding.
    let data = std::fs::read(path)
        .with_context(|| format!("Failed to read audio file: {}", path.display()))?;

    // Fast path for WAV files — use qwen-asr's native parser.
    if ext == "wav" {
        // Attempt once; success returns immediately, failure falls through
        // to the generic decoder match below (which will fail with a clear
        // error rather than calling parse_wav_buffer a second time).
        if let Some(samples) = qwen_asr::audio::parse_wav_buffer(&data) {
            return Ok(samples);
        }
    }
    // If the WAV parser fails, fall through to the generic decoder.

    if data.len() < 8 {
        anyhow::bail!("Audio file too small: {}", path.display());
    }

    let (samples, sample_rate) = match ext.as_str() {
        "ogg" | "oga" => decode_opus_from_ogg(&data, path)?,
        "mp3" => decode_mp3(&data, path)?,
        "wav" => {
            // Fast-path above already failed — don't retry the same call.
            anyhow::bail!(
                "Failed to decode WAV file (format error): {}",
                path.display()
            );
        }
        // For unknown formats, try OGG/Opus (common Telegram format).
        _ => {
            // Check for OGG magic bytes.
            if data.len() >= 4 && &data[0..4] == b"OggS" {
                decode_opus_from_ogg(&data, path)?
            } else {
                anyhow::bail!(
                    "Unsupported audio format '.{ext}' — only WAV, OGG/Opus, and MP3 are supported"
                );
            }
        }
    };

    // Resample to 16 kHz if needed.
    if sample_rate == qwen_asr::config::SAMPLE_RATE {
        Ok(samples)
    } else {
        let resampled =
            qwen_asr::audio::resample(&samples, sample_rate, qwen_asr::config::SAMPLE_RATE);
        Ok(resampled)
    }
}

/// Decode an OGG/Opus file into mono f32 samples at the file's sample rate.
///
/// Uses the `ogg` crate for OGG demuxing and the `opus_decoder` crate for
/// pure-Rust Opus decoding (no C dependencies, no unsafe code).
fn decode_opus_from_ogg(data: &[u8], path: &Path) -> Result<(Vec<f32>, i32)> {
    use ogg::reading::PacketReader;
    use std::io::Cursor;

    let cursor = Cursor::new(data);
    let mut reader = PacketReader::new(cursor);
    let sample_rate: i32 = 16000; // We decode at 16 kHz
    let mut decoder: Option<opus_decoder::OpusDecoder> = None;
    let mut channels: usize = 1; // Will be set from OpusHead
    let mut samples: Vec<f32> = Vec::new();

    loop {
        let packet = match reader.read_packet() {
            Ok(Some(pkt)) => pkt,
            Ok(None) => break, // End of stream
            Err(e) => {
                warn!(path = %path.display(), error = %e, "OGG demux error, stopping");
                break;
            }
        };

        let packet_data = packet.data;

        // The first packet is the Opus identification header
        if decoder.is_none() {
            if packet_data.starts_with(b"OpusHead") {
                // OpusHead: magic(8) + version(1) + channels(1) + pre-skip(2) + input_sample_rate(4) + ...
                if packet_data.len() < 18 {
                    anyhow::bail!("Invalid Opus identification header (too short)");
                }
                channels = packet_data[9] as usize;
                match opus_decoder::OpusDecoder::new(16000u32, channels) {
                    Ok(d) => {
                        decoder = Some(d);
                    }
                    Err(e) => {
                        anyhow::bail!("Failed to create Opus decoder: {e:?}");
                    }
                }
            } else {
                anyhow::bail!("OGG file does not contain Opus data");
            }
            continue;
        }

        // Skip the OpusTags comment header packet — it's metadata, not audio
        // data, and feeding it to decode_float produces a spurious decode error.
        if packet_data.starts_with(b"OpusTags") {
            continue;
        }

        if packet_data.is_empty() {
            continue; // Empty packet
        }

        // Decode to f32. Max packet: ~120ms at 48kHz stereo = 5760 samples/channel.
        // Use generous buffer with margin for multichannel.
        let max_pcm_len = 5760 * channels.max(2);
        let mut pcm = vec![0.0f32; max_pcm_len];
        let dec = decoder.as_mut().ok_or_else(|| {
            anyhow::anyhow!("Opus decoder not initialized — missing OpusHead header")
        })?;
        match dec.decode_float(&packet_data, &mut pcm, false) {
            Ok(n_per_channel) => {
                if channels == 1 {
                    samples.extend_from_slice(&pcm[..n_per_channel]);
                } else {
                    // Stereo → mono by averaging (midpoint avoids overflow and
                    // rounds identically to (l + r) * 0.5 for normal samples).
                    for i in 0..n_per_channel {
                        let l = pcm[i * 2];
                        let r = pcm[i * 2 + 1];
                        samples.push(l.midpoint(r));
                    }
                }
            }
            Err(e) => {
                warn!(path = %path.display(), error = ?e, "Opus decode error, skipping packet");
            }
        }
    }

    if samples.is_empty() {
        anyhow::bail!("No audio decoded from {}", path.display());
    }

    Ok((samples, sample_rate))
}

/// Decode an MP3 file into mono f32 samples at the file's sample rate.
///
/// Uses the `minimp3` crate — a Rust wrapper around the minimp3 C library.
/// This introduces a C compiler build dependency (`minimp3-sys` + `cc` crate).
/// If a pure-Rust MP3 decoder is needed in the future, one could replace this
/// with a subprocess call to `ffmpeg` (handling all audio formats) or a
/// pure-Rust MP3 crate like `mp3-dl`.
#[expect(clippy::cast_precision_loss)]
fn decode_mp3(data: &[u8], path: &Path) -> Result<(Vec<f32>, i32)> {
    use minimp3::Decoder as Mp3Decoder;

    let mut decoder = Mp3Decoder::new(data);
    let mut samples: Vec<f32> = Vec::new();
    let mut sample_rate: i32 = 0;

    loop {
        match decoder.next_frame() {
            Ok(frame) => {
                // frame.data is Vec<i16> in [L,R,L,R,...] interleaved or [L,L,...] for mono.
                // frame.channels is the number of channels.
                // frame.sample_rate is the sample rate in Hz.
                if sample_rate == 0 {
                    sample_rate = frame.sample_rate;
                }

                let n_ch = frame.channels;
                if n_ch == 1 {
                    // Mono: convert i16 to f32 in [-1.0, 1.0]
                    for &val in &frame.data {
                        samples.push(f32::from(val) / 32768.0);
                    }
                } else {
                    // Stereo/multi: average to mono.
                    for chunk in frame.data.chunks(n_ch) {
                        let mono: f32 = chunk.iter().map(|&v| f32::from(v)).sum::<f32>()
                            / n_ch as f32
                            / 32768.0;
                        samples.push(mono);
                    }
                }
            }
            Err(minimp3::Error::Eof) => break,
            Err(e) => {
                warn!(path = %path.display(), error = %e, "MP3 decode error, stopping");
                break;
            }
        }
    }

    if samples.is_empty() {
        anyhow::bail!("No audio decoded from {}", path.display());
    }

    Ok((samples, sample_rate))
}

// ── File download helpers ─────────────────────────────────────────────

/// Structure describing a file to download.
struct ModelFile {
    filename: &'static str,
    url: String,
    expected_sha256: &'static str,
    timeout: Duration,
}

/// Build the URL for a model file in the HuggingFace repo.
fn model_url(filename: &str) -> String {
    format!("https://huggingface.co/{MODEL_REPO}/resolve/main/{filename}")
}

/// Build the list of all required model files.
fn model_files() -> [ModelFile; 3] {
    [
        ModelFile {
            filename: MODEL_FILENAME,
            url: model_url(MODEL_FILENAME),
            expected_sha256: MODEL_SHA256,
            timeout: MODEL_DOWNLOAD_TIMEOUT,
        },
        ModelFile {
            filename: VOCAB_FILENAME,
            url: model_url(VOCAB_FILENAME),
            expected_sha256: VOCAB_SHA256,
            timeout: SMALL_FILE_TIMEOUT,
        },
        ModelFile {
            filename: MERGES_FILENAME,
            url: model_url(MERGES_FILENAME),
            expected_sha256: MERGES_SHA256,
            timeout: SMALL_FILE_TIMEOUT,
        },
    ]
}

/// Download a file from `url` to `dest`, verifying SHA256.
///
/// On SHA256 mismatch, the partially downloaded file is removed so the next
/// attempt re-downloads from scratch.
async fn download_file(client: &reqwest::Client, file: &ModelFile, dest: &Path) -> Result<()> {
    #[expect(clippy::cast_precision_loss)]
    fn calc_pct(downloaded: u64, total_size: u64) -> f64 {
        (downloaded as f64 / total_size as f64 * 100.0).min(100.0)
    }

    crate::util::http::download_verified(
        client,
        &file.url,
        dest,
        file.expected_sha256,
        Some(file.timeout),
        crate::util::http::DownloadSizeCheck::None,
        |downloaded, total_size| {
            if total_size > 0 {
                let pct = calc_pct(downloaded, total_size);
                debug!(
                    "Downloading {}: {:.0}% ({}/{} MB)",
                    file.filename,
                    pct,
                    downloaded / 1_048_576,
                    total_size / 1_048_576,
                );
            }
        },
    )
    .await
    .with_context(|| format!("Failed to download {}", file.filename))?;

    info!("Downloaded {} ({})", file.filename, file.expected_sha256);
    Ok(())
}

/// Create a reqwest client with sensible defaults for model downloads.
///
/// Deliberately not [`crate::util::http::build_download_client`] — no client
/// timeouts: per-request timeouts already bound each download (30 min model,
/// 1 min small files) and the custom user-agent is kept. Connect-phase
/// fail-fast is intentionally absent.
fn download_client() -> Result<reqwest::Client> {
    crate::util::http::install_ring_provider();
    reqwest::Client::builder()
        .user_agent("mahbot/0.3.0 (qwen-asr model downloader)")
        .build()
        .context("Failed to create HTTP client for model download")
}

/// Background download loop with retry.
///
/// Downloads all model files sequentially. On failure, sleeps with exponential
/// backoff and retries up to [`MAX_DOWNLOAD_RETRIES`] times. On success,
/// loads the model and transitions to [`ModelState::Ready`]. On terminal failure,
/// transitions to [`ModelState::Failed`].
///
/// Panic-safety: a [`ModelLoadGuard`] transitions `Loading → Failed` on drop,
/// so a panic or cancellation in this task never leaves [`STATE`] stuck in
/// [`ModelState::Loading`] (which would silently disable transcription until
/// restart). Since the boot path now drives this loop from a background task,
/// a stuck-Loading state would be both silent and permanent — Failed is the
/// honest terminal state.
async fn download_retry_loop() {
    // Loading → Failed on drop (panic/cancel safety). No-op on the terminal
    // states set by the success/retry-cap paths below.
    let _guard = ModelLoadGuard::new(&STATE);
    let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
        warn!("Local transcriber: cannot resolve model directory (storage root not set)");
        STATE.store(ModelState::Failed, Ordering::Release);
        return;
    };

    let client = match download_client() {
        Ok(c) => c,
        Err(e) => {
            warn!("Local transcriber: failed to create HTTP client: {e}");
            STATE.store(ModelState::Failed, Ordering::Release);
            return;
        }
    };

    tokio::fs::create_dir_all(&dir).await.ok();

    let files = model_files();
    let mut attempt: u32 = 0;

    loop {
        attempt += 1;
        let mut all_ok = true;

        for file in &files {
            let dest = dir.join(file.filename);
            if dest.exists() {
                // Verify existing file on a blocking thread (SHA256 reads
                // the full 1.88 GB model file).
                let dest_clone = dest.clone();
                let expected = file.expected_sha256.to_string();
                let checksum_ok = tokio::task::spawn_blocking(move || {
                    crate::util::verify_sha256(&dest_clone, &expected).is_ok()
                })
                .await
                .unwrap_or_else(|join_err| {
                    warn!("Local transcriber: SHA256 verification task panicked: {join_err}");
                    false
                }); // JoinError → log and treat as checksum mismatch, will re-download

                if checksum_ok {
                    continue;
                }
                warn!(
                    "Local transcriber: {} SHA256 mismatch, re-downloading",
                    file.filename
                );
                // Remove the corrupted file so the download starts fresh.
                tokio::fs::remove_file(&dest).await.ok();
            }

            info!(
                "Local transcriber: downloading {} — attempt {attempt}/{MAX_DOWNLOAD_RETRIES}",
                file.filename,
            );

            match download_file(&client, file, &dest).await {
                Ok(()) => {
                    // download_file already verifies SHA256 during streaming
                    // and removes the file on mismatch, so no re-verification needed.
                }
                Err(e) => {
                    warn!(
                        "Local transcriber: failed to download {}: {e}",
                        file.filename
                    );
                    tokio::fs::remove_file(&dest).await.ok();
                    all_ok = false;
                    break;
                }
            }
        }

        if all_ok {
            // All files downloaded and verified. Load the model on a blocking
            // thread (reads model weights from disk).
            info!("Local transcriber: all model files downloaded, loading...");
            let dir_for_load = dir.clone();
            let loaded = tokio::task::spawn_blocking(move || {
                QwenLocalTranscriber::try_load_from(&dir_for_load)
            })
            .await
            .ok()
            .flatten();
            if let Some(tc) = loaded {
                info!("Local transcriber: Qwen3-ASR model loaded successfully");
                set_transcriber_ready(tc);
                return;
            }
            warn!(
                "Local transcriber: model files present but failed to load — deleting and re-downloading"
            );
            // Delete corrupted files to force a fresh download with
            // SHA256 verification, following the embedder pattern
            // (embedder.rs Phase 1 logic).
            for f in &files {
                let dest = dir.join(f.filename);
                tokio::fs::remove_file(&dest).await.ok();
            }
        }

        if attempt >= MAX_DOWNLOAD_RETRIES {
            warn!("Local transcriber: max retries ({MAX_DOWNLOAD_RETRIES}) reached, giving up");
            STATE.store(ModelState::Failed, Ordering::Release);
            return;
        }

        let sleep_secs = DOWNLOAD_RETRY_BASE_SECS * (1u64 << (attempt - 1).min(8));
        let sleep_dur = Duration::from_secs(sleep_secs.min(300));
        warn!(
            "Local transcriber: retrying in {}s (attempt {attempt}/{MAX_DOWNLOAD_RETRIES})",
            sleep_dur.as_secs()
        );
        tokio::time::sleep(sleep_dur).await;
    }
}

// ── Public API ────────────────────────────────────────────────────────

/// Load the transcriber from an already-resolved cache directory on a blocking
/// thread (memory-map + weight setup). Returns `None` when any file is missing
/// or the load fails.
async fn load_from_cache(dir: PathBuf) -> Option<QwenLocalTranscriber> {
    tokio::task::spawn_blocking(move || QwenLocalTranscriber::try_load_from(&dir))
        .await
        .ok()
        .flatten()
}

/// Shared cache-check + load logic used by both the awaited entry
/// ([`try_init_from_cache`]) and the background boot init
/// ([`init_background`]). The caller is responsible for the STATE atomic
/// guard.
///
/// Decision 1 (mahbot-1709): no per-boot SHA256 re-verification — files are
/// verified once at download time (`download_verified` hashes the stream; the
/// download loop re-verifies already-present files on recovery). A silently
/// corrupted cache is caught at load time instead: a `None` from
/// [`load_from_cache`] falls through to the download+verify recovery loop.
/// (Tradeoff: the mmap-based load can succeed on partial corruption — accepted
/// per ticket decision 1; see module docs.)
async fn try_init_inner(dir: PathBuf) -> bool {
    let model_path = dir.join(MODEL_FILENAME);
    let vocab_path = dir.join(VOCAB_FILENAME);
    let merges_path = dir.join(MERGES_FILENAME);

    if model_path.exists() && vocab_path.exists() && merges_path.exists() {
        if let Some(tc) = load_from_cache(dir).await {
            info!("Local transcriber: loaded from cache");
            set_transcriber_ready(tc);
            return true;
        }
        warn!("Local transcriber: cached files present but failed to load");
    }

    // Spawn background download.
    if tokio::runtime::Handle::try_current().is_err() {
        warn!("Local transcriber: no tokio runtime available");
        STATE.store(ModelState::Failed, Ordering::Release);
        return false;
    }

    info!("Local transcriber: model not cached, spawning background download");
    tokio::spawn(download_retry_loop());
    false
}

/// Background load-or-download chain for the boot path — spawned by
/// [`spawn_background_init`] and never awaited by the boot path.
///
/// STATE must already be `Loading` (the caller owns the `Uninit → Loading`
/// transition via [`try_lock_init`]). Loads from cache when all files are
/// present; otherwise runs the download+verify retry loop to completion (it is
/// already in the background — no need to detach it further). Both paths
/// transition STATE to a terminal state ([`set_transcriber_ready`] /
/// `Failed`).
async fn init_background(dir: PathBuf) {
    let model_path = dir.join(MODEL_FILENAME);
    let vocab_path = dir.join(VOCAB_FILENAME);
    let merges_path = dir.join(MERGES_FILENAME);

    if model_path.exists() && vocab_path.exists() && merges_path.exists() {
        if let Some(tc) = load_from_cache(dir).await {
            info!("Local Qwen3-ASR transcriber loaded from cache");
            set_transcriber_ready(tc);
            return;
        }
        warn!("Local transcriber: cached files present but failed to load — re-downloading");
    }

    info!("Local transcriber: model not cached, downloading in background");
    download_retry_loop().await;
}

/// Try to shortcut or acquire the initialisation lock.
///
/// Returns `None` if the caller acquired the lock and should proceed.
/// Returns `Some(true)` if already initialised (ModelState::Ready) — caller
/// should return success.
/// Returns `Some(false)` if another thread is loading or a previous
/// attempt failed — caller should return failure.
fn try_lock_init() -> Option<bool> {
    match STATE.load(Ordering::Acquire) {
        ModelState::Ready => return Some(true),
        ModelState::Uninit => {}
        _ => return Some(false),
    }

    if !STATE.transition(ModelState::Uninit, ModelState::Loading) {
        return Some(false);
    }

    None
}

/// Initialise the local transcriber from the default cache directory
/// (resolved via [`crate::audio::models_subdir`], which depends on the CONFIG
/// storage root).
///
/// Awaits the cache load (fast with warm page cache — no per-boot SHA256), or
/// spawns a detached background download when the cache is missing/corrupt.
/// Used by the config-save path ([`crate::providers::recreate_all`]) and the
/// TTS e2e test; the boot path uses [`spawn_background_init_if_enabled`]
/// instead so it never waits on the load.
pub async fn try_init_from_cache() -> bool {
    if let Some(result) = try_lock_init() {
        return result;
    }

    let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
        STATE.store(ModelState::Failed, Ordering::Release);
        return false;
    };

    try_init_inner(dir).await
}

/// Kick off the local ASR transcriber's load-or-download chain as a background
/// task (boot path, mahbot-1709 decisions 2/3).
///
/// The boot path never awaits this: the app and background services start
/// regardless, and the ~4 s model load (or a full download when the cache is
/// missing) runs concurrently with the rest of boot. The load starts here —
/// it is **not** deferred until the first voice message.
///
/// No-op when the transcriber is already ready/loading, or when a previous
/// attempt failed (Failed is terminal for the automatic lifecycle — only the
/// voice pipeline's bounded auto-retry or the explicit GUI retry
/// ([`retry_init`]) re-attempts).
/// Panic-safe: a panic inside the spawned chain transitions STATE to
/// [`ModelState::Failed`] instead of leaving it stuck in Loading.
pub fn spawn_background_init() {
    if let Some(result) = try_lock_init() {
        // Ready (true) — already loaded. Loading/Failed (false) — an init is
        // already in flight or a previous attempt failed terminally; nothing
        // to start.
        debug!(result, "Local transcriber: background init skipped");
        return;
    }

    let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
        warn!("Local transcriber: cannot resolve model directory (storage root not set)");
        STATE.store(ModelState::Failed, Ordering::Release);
        return;
    };

    tokio::spawn(async move {
        // A panic must never leave STATE stuck in Loading (silently disabling
        // transcription until restart) — store the honest terminal state.
        let result = AssertUnwindSafe(init_background(dir)).catch_unwind().await;
        if result.is_err() {
            warn!("Local transcriber: background init panicked — marking failed");
            STATE.store(ModelState::Failed, Ordering::Release);
        }
    });
}

/// Boot entry: spawn the ASR background init unless `audio_transcription_use_local`
/// is explicitly `"false"` (the config store must already be loaded — the
/// caller runs this after [`crate::config::reload_from_db`]).
pub fn spawn_background_init_if_enabled() {
    let disabled = crate::config::CONFIG
        .snapshot()
        .audio_transcription_use_local
        .as_deref()
        == Some("false");
    if disabled {
        tracing::debug!(
            "Local audio transcription is disabled by config — skipping background init"
        );
        return;
    }
    spawn_background_init();
}

/// True if the local transcriber is loaded and ready for use.
pub fn is_loaded() -> bool {
    STATE.is_ready()
}

/// True if the local transcriber failed to load (terminal `Failed` state).
///
/// Used by the voice pipeline's model gating to surface
/// [`VoiceStatus::ModelError`] when the shared ASR model is unavailable.
pub fn is_failed() -> bool {
    STATE.load(Ordering::Acquire) == ModelState::Failed
}

/// Clone the shared Qwen3-ASR model Arc for encoder reuse by the wake-word
/// pipeline.
///
/// Returns `None` when the transcriber is not loaded (or still loading).
/// The returned model shares the exact same loaded weights as transcription —
/// the wake-word pipeline performs no separate download or model handling.
///
/// Reads the lock-free [`SHARED_MODEL`] slot populated by
/// [`set_transcriber_ready`] — it NEVER touches the transcription context
/// mutex, which [`transcribe_file_async`] holds for the full duration of an
/// inference (up to [`INFERENCE_TIMEOUT`]).  The wake-word stride path calls
/// this on every scoring step; locking the ctx there would block the whole
/// voice pipeline behind any concurrent transcription.
pub fn shared_model_arc() -> Option<Arc<qwen_asr::context::QwenModel>> {
    SHARED_MODEL.lock().unwrap_poison().clone()
}

/// Retry initialisation after a terminal `Failed` state.
///
/// The default transcriber lifecycle treats `Failed` as terminal — no
/// automatic re-attempt.  Two explicit recovery knobs exist: the voice
/// pipeline's bounded periodic auto-retry (at most
/// [`MAX_AUTO_MODEL_RETRY_CYCLES`](crate::audio::voice::MAX_AUTO_MODEL_RETRY_CYCLES)
/// cycles per session) and the GUI retry button
/// ([`VoiceCommand::RetryModelLoading`]), which calls this directly and
/// bypasses the auto-retry budget.  This function resets `Failed` → `Uninit`
/// and re-runs the background init chain (load-or-download).  Returns `true`
/// if a retry was initiated; `false` when the state was not `Failed` or the
/// claim was lost.
pub fn retry_init() -> bool {
    if !STATE.transition(ModelState::Failed, ModelState::Uninit) {
        return false;
    }
    spawn_background_init();
    true
}

// ── Tests ─────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── hex_string ─────────────────────────────────────────────────────

    #[test]
    fn test_hex_string_empty() {
        assert_eq!(crate::util::hex_string(b""), "");
    }

    #[test]
    fn test_hex_string_all_bytes() {
        let bytes: Vec<u8> = (0..=255u8).collect();
        let hex = crate::util::hex_string(&bytes);
        assert_eq!(hex.len(), 512);
        assert!(hex.starts_with("00010203"));
        assert!(hex.ends_with("fcfdfeff"));
    }

    // ── decode_audio_to_mono_f32 — synthetic WAV ────────────────────────

    /// Create a minimal valid 16-bit mono PCM WAV file in a temp directory.
    fn write_synthetic_wav(
        dir: &std::path::Path,
        filename: &str,
        sample_rate: u32,
    ) -> std::path::PathBuf {
        let path = dir.join(filename);

        // 1-second 440 Hz sine; encode via the shared production WAV encoder.
        let num_samples = sample_rate as usize;
        let samples: Vec<f32> = (0..num_samples)
            .map(|i| {
                #[expect(clippy::cast_precision_loss)] // i < sample_rate (≤16 kHz) — exact in f32
                let t = i as f32 / sample_rate as f32;
                (t * 440.0 * 2.0 * std::f32::consts::PI).sin()
            })
            .collect();
        let wav = crate::audio::tts::render_wav(&samples, sample_rate).unwrap();

        std::fs::write(&path, wav).unwrap();
        path
    }

    #[test]
    fn test_decode_wav_mono_16k() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_synthetic_wav(dir.path(), "test.wav", 16000);
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_ok(),
            "Failed to decode 16 kHz WAV: {:?}",
            result.err()
        );
        let samples = result.unwrap();
        assert!(!samples.is_empty(), "Decoded samples should not be empty");
        // At 16 kHz, 1 second = 16000 samples
        assert_eq!(
            samples.len(),
            16000,
            "Expected 16000 samples for 1 second at 16 kHz"
        );
        // Check that samples are in valid f32 range
        for &s in &samples {
            assert!((-1.0..=1.0).contains(&s), "Sample {s} out of range");
        }
        // Verify first few samples approximate a sine wave starting near 0
        assert!(
            (samples[0]).abs() < 0.01,
            "First sample should be near 0 for sine starting at t=0"
        );
    }

    #[test]
    fn test_decode_wav_mono_48k_resampled() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_synthetic_wav(dir.path(), "test48k.wav", 48000);
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_ok(),
            "Failed to decode 48 kHz WAV: {:?}",
            result.err()
        );
        let samples = result.unwrap();
        // Resampled to 16 kHz — 1 second at 48 kHz → ~16000 samples after resampling
        assert!(!samples.is_empty(), "Decoded samples should not be empty");
        // Allow some tolerance for resampling (should be close to 16000)
        assert!(
            samples.len() >= 15500 && samples.len() <= 16500,
            "Expected ~16000 samples after resampling from 48 kHz, got {}",
            samples.len()
        );
    }

    #[test]
    fn test_decode_wav_empty_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("empty.wav");
        std::fs::write(&path, b"").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Empty file should fail to decode");
    }

    #[test]
    fn test_decode_wav_too_small() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("tiny.wav");
        std::fs::write(&path, b"RIFF").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Truncated WAV should fail to decode");
    }

    // ── decode_audio_to_mono_f32 — extension handling ──────────────────

    #[test]
    fn test_decode_unknown_extension() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("audio.xyz");
        std::fs::write(&path, b"not an audio file").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Unknown extension should fail to decode");
    }

    #[test]
    fn test_decode_no_extension() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("noext");
        std::fs::write(&path, b"not an audio file").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_err(),
            "File without extension should fail to decode"
        );
    }

    // ── OGG/Opus decode ────────────────────────────────────────────────

    /// Construct a minimal valid OGG/Opus byte stream in memory.
    fn create_test_opus_ogg() -> Vec<u8> {
        use ogg::writing::{PacketWriteEndInfo, PacketWriter};

        let mut buf = Vec::new();
        let serial = 1u32;

        // Scoped so the PacketWriter flushes before we read the buf.
        {
            let mut writer = PacketWriter::new(&mut buf);

            // Opus identification header (OpusHead)
            let mut head = Vec::new();
            head.extend_from_slice(b"OpusHead"); // magic
            head.push(1); // version
            head.push(1); // channels (mono)
            head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip
            head.extend_from_slice(&48000u32.to_le_bytes()); // input sample rate
            head.extend_from_slice(&0u16.to_le_bytes()); // output gain
            head.push(0); // channel mapping family
            writer
                .write_packet(head, serial, PacketWriteEndInfo::EndPage, 0)
                .unwrap();

            // Opus comment header (OpusTags) — minimal
            let vendor = b"test";
            let mut tags = Vec::new();
            tags.extend_from_slice(b"OpusTags");
            tags.extend_from_slice(&u32::try_from(vendor.len()).unwrap().to_le_bytes());
            tags.extend_from_slice(vendor);
            tags.extend_from_slice(&0u32.to_le_bytes()); // user comment list length = 0
            writer
                .write_packet(tags, serial, PacketWriteEndInfo::EndPage, 0)
                .unwrap();

            // Audio data: a zero-byte packet triggers packet-loss concealment,
            // which produces silence (no crash).
            writer
                .write_packet(b"", serial, PacketWriteEndInfo::EndStream, 0)
                .unwrap();
        }
        buf
    }

    #[test]
    fn test_decode_opus_ogg_valid_headers() {
        let data = create_test_opus_ogg();
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("voice.ogg");
        std::fs::write(&path, &data).unwrap();
        // The empty audio packet triggers PLC which produces 0 samples (no
        // prior decoder state). The function should not panic and should return
        // a meaningful error about no audio being decoded.
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_err(),
            "OGG/Opus with headers + silence should produce 'No audio decoded'"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("No audio decoded") || err.contains("decode"),
            "Unexpected error: {err}"
        );
    }

    #[test]
    fn test_decode_opus_ogg_truncated() {
        let data = create_test_opus_ogg();
        let truncated = &data[..data.len().min(64)];
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("truncated.ogg");
        std::fs::write(&path, truncated).unwrap();
        let result = decode_audio_to_mono_f32(&path);
        // Truncated OGG should produce an error from the OGG demuxer
        // (missing pages or incomplete Opus headers).
        assert!(result.is_err(), "Truncated OGG/Opus should fail to decode");
    }

    #[test]
    fn test_decode_opus_ogg_invalid_data() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fake.ogg");
        std::fs::write(&path, b"not an OGG file at all").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Invalid OGG data should fail to decode");
    }

    #[test]
    fn test_decode_opus_ogg_no_opus_head() {
        // Create an OGG page with no OpusHead (just invalid data in an OGG page).
        let data = b"OggS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00invalid";
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nohead.ogg");
        std::fs::write(&path, data).unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_err(),
            "OGG without OpusHead should fail to decode"
        );
    }

    // ── MP3 decode ──────────────────────────────────────────────────────

    /// Create a minimal valid MPEG2.5 Layer III frame.
    ///
    /// Frame parameters: 8 kbps, 8 kHz, mono, no CRC.
    /// Frame size = (144 * 8) / 8 = 144 bytes.
    fn create_test_mp3_frame() -> Vec<u8> {
        // Header: sync|version|layer|prot  bitrate|srate|pad|priv  mode|ext|copy|orig|emp
        //         0xFF    0xE3            0x18                   0xC0
        let mut frame = vec![0xFF, 0xE3, 0x18, 0xC0];
        frame.resize(144, 0u8);
        frame
    }

    #[test]
    fn test_decode_mp3_valid_frame() {
        let data = create_test_mp3_frame();
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.mp3");
        std::fs::write(&path, &data).unwrap();
        let result = decode_audio_to_mono_f32(&path);
        // The minimp3 decoder may produce output (even from zeroed data)
        // or it may return SkippedData that our decode_mp3 treats as EOF.
        // Either way the function should not panic.
        if let Err(e) = &result {
            assert!(
                e.to_string().contains("No audio decoded") || e.to_string().contains("Unsupported"),
                "Unexpected error: {e}"
            );
        }
    }

    #[test]
    fn test_decode_mp3_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("empty.mp3");
        std::fs::write(&path, b"").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Empty MP3 should fail to decode");
    }

    #[test]
    fn test_decode_mp3_truncated_header() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("truncated.mp3");
        std::fs::write(&path, b"\xFF\xFB").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(
            result.is_err(),
            "Truncated MP3 header should fail to decode"
        );
    }

    #[test]
    fn test_decode_mp3_non_mp3_data() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fake.mp3");
        std::fs::write(&path, b"this is not an mp3 file at all").unwrap();
        let result = decode_audio_to_mono_f32(&path);
        assert!(result.is_err(), "Non-MP3 data should fail to decode");
    }
}