diff --git a/crates/opus/examples/bench_dec.rs b/crates/opus/examples/bench_dec.rs
new file mode 100644
--- /dev/null
+++ b/crates/opus/examples/bench_dec.rs
@@ -0,0 +1,69 @@
+//! Throwaway decoder benchmark (untracked; do not commit).
+//! bench_dec <rate> <reps> <out.f32|-> <file.opus|file.pkt>
+use filmcraft_opus::{Packet, StreamDecoder};
+
+fn ogg_packets(data: &[u8]) -> Vec<Vec<u8>> {
+    let mut packets = Vec::new();
+    let mut cur = Vec::new();
+    let mut pos = 0;
+    while pos + 27 <= data.len() && &data[pos..pos + 4] == b"OggS" {
+        let nseg = data[pos + 26] as usize;
+        let table = &data[pos + 27..pos + 27 + nseg];
+        let mut body = pos + 27 + nseg;
+        for &l in table {
+            cur.extend_from_slice(&data[body..body + l as usize]);
+            body += l as usize;
+            if l < 255 {
+                packets.push(std::mem::take(&mut cur));
+            }
+        }
+        pos = body;
+    }
+    packets
+}
+
+fn main() {
+    let a: Vec<String> = std::env::args().collect();
+    let rate: u32 = a[1].parse().unwrap();
+    let reps: usize = a[2].parse().unwrap();
+    let out_path = &a[3];
+    let path = &a[4];
+    let data = std::fs::read(path).unwrap();
+    let (ch, packets) = if path.ends_with(".opus") {
+        let p = ogg_packets(&data);
+        (p[0][9] as usize, p[2..].to_vec())
+    } else {
+        let ch = data[0] as usize;
+        let mut pos = 3;
+        let mut v = Vec::new();
+        while pos + 2 <= data.len() {
+            let l = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize;
+            pos += 2;
+            v.push(data[pos..pos + l].to_vec());
+            pos += l;
+        }
+        (ch, v)
+    };
+    let mut modes = [0usize; 3];
+    for p in &packets {
+        if let Ok(pk) = Packet::parse(p) {
+            modes[pk.toc.mode as usize] += 1;
+        }
+    }
+    let t0 = std::time::Instant::now();
+    let mut out = Vec::with_capacity(packets.len() * 960 * ch);
+    for _ in 0..reps {
+        out.clear();
+        let mut d = StreamDecoder::new(rate, ch).unwrap();
+        for p in &packets {
+            d.decode_interleaved(Some(p), &mut out).unwrap();
+        }
+    }
+    let dt = t0.elapsed().as_secs_f64() / reps as f64;
+    let secs = out.len() as f64 / ch as f64 / rate as f64;
+    eprintln!("{path}: ch={ch} modes={modes:?} {:.4}s per pass for {:.1}s audio ({:.0}x realtime)", dt, secs, secs / dt);
+    if out_path != "-" {
+        let bytes: Vec<u8> = out.iter().flat_map(|v| v.to_le_bytes()).collect();
+        std::fs::write(out_path, bytes).unwrap();
+    }
+}
diff --git a/crates/opus/src/decoder.rs b/crates/opus/src/decoder.rs
--- a/crates/opus/src/decoder.rs
+++ b/crates/opus/src/decoder.rs
@@ -109,12 +109,20 @@ impl StreamDecoder {
         }
     }
 
-    /// Conceals `n` samples (a multiple of 2.5 ms).
+    /// Longest concealment or FEC span accepted per call: 120 ms, the longest Opus packet.
+    fn max_span(&self) -> usize {
+        self.rate as usize * 3 / 25
+    }
+
+    /// Conceals `n` samples (a multiple of 2.5 ms, at most 120 ms).
     pub fn conceal(&mut self, n: usize, out: &mut Vec<f32>) -> Result<usize> {
         let f2_5 = self.rate as usize / 400;
         if n == 0 || !n.is_multiple_of(f2_5) {
             return Err(Error::InvalidArgument("concealment length must be a multiple of 2.5 ms"));
         }
+        if n > self.max_span() {
+            return Err(Error::InvalidArgument("concealment length exceeds 120 ms"));
+        }
         let start = out.len();
         out.resize(start + n * self.channels, 0.0);
         let mut done = 0;
@@ -127,7 +135,11 @@ impl StreamDecoder {
     }
 
     /// Decodes the in-band FEC (LBRR) of `packet` for the previous (lost) packet of `n` samples.
+    /// `n` is at most 120 ms.
     pub fn decode_fec(&mut self, packet: &[u8], n: usize, out: &mut Vec<f32>) -> Result<usize> {
+        if n > self.max_span() {
+            return Err(Error::InvalidArgument("FEC length exceeds 120 ms"));
+        }
         let p = Packet::parse(packet)?;
         let pfs = p.toc.frame_samples(self.rate);
         if n < pfs || p.toc.mode == Mode::CeltOnly || self.mode == Some(Mode::CeltOnly) {
diff --git a/crates/opus/tests/it/robustness.rs b/crates/opus/tests/it/robustness.rs
--- a/crates/opus/tests/it/robustness.rs
+++ b/crates/opus/tests/it/robustness.rs
@@ -75,6 +75,23 @@ fn concealment_before_and_after_packets() {
     assert!(out[0].iter().all(|&v| v == 0.0));
 }
 
+/// A caller-supplied loss span must not allocate without limit: more than 120 ms is rejected
+/// before any output is reserved, 120 ms still works.
+#[test]
+fn concealment_length_is_capped() {
+    let mut dec = StreamDecoder::new(48000, 2).unwrap();
+    let mut out = Vec::new();
+    assert_eq!(dec.conceal(5760, &mut out).unwrap(), 5760);
+    assert_eq!(out.len(), 5760 * 2);
+    out.clear();
+    for n in [5760 + 120, 48000 * 10, usize::MAX - usize::MAX % 120] {
+        assert!(dec.conceal(n, &mut out).is_err(), "n={n}");
+        // The length is checked before the packet is parsed, so any bytes will do.
+        assert!(dec.decode_fec(&[0xF8, 0xFF, 0xFE], n, &mut out).is_err(), "fec n={n}");
+    }
+    assert!(out.is_empty());
+}
+
 fn vectors_dir() -> Option<PathBuf> {
     let d = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/opus/vectors");
     d.join("testvector01.bit").exists().then_some(d)
