Acouvero
Download
TECHNICAL DOC · v0.4.0

Room acoustics,
full-stack — from
measurement to plan.

Acouvero is a room-acoustics analysis and treatment-planning tool for iPhone / iPad. This document is for readers interested in the acoustics engineering or the client implementation — covering the core algorithms, geometry system, measurement methods, recommendation engine and engineering notes.

Octave bands
6bands
Room shapes
4geometries
Reflections
1–3orders
§ 01

Overview

Input: the user enters room size / shape / finish materials, speaker and listening-position locations — or the room geometry is measured with the AR tape measure (no LiDAR needed) or captured via a LiDAR (RoomPlan) scan.

Output
  • Early-reflection point localization
  • RT60 (measured + simulated) plus EDT / C50 / C80 / D50 (derived from the simulated RIR)
  • Room-mode frequency list
  • Panel layout plan (position, count, product SKU)
  • 3D visualization + reflection-energy heatmap
  • Installation-manual-grade PDF export
  • On-device measurement via the iPhone mic (sweep / clap)

Rectangular, L-, U-shaped and arbitrary simple-polygon rooms are supported; first-order reflections and area / containment math use the true polygon; 2nd/3rd-order reflections and room modes approximate via the bounding box (see §9).

§ 02

Acoustic model

2.1 Sabine RT60

The classic Sabine formula:

SABINE · RT60 $$ RT_{60} = \frac{0.161 \cdot V}{\sum_i S_i \alpha_i} $$
$V$
Room volume (m³); for polygons, Shoelace area × height, minus obstacles such as columns / beams.
$\sum_i S_i \alpha_i$
Surface area × absorption coefficient per face. Coefficients use 6 octave bands (125 / 250 / 500 / 1k / 2k / 4k Hz).
$0.161$
= 24·ln(10)/c, with c = 343 m/s (20 °C).

The app ships 4 finish presets (sparse hard-surfaced / typical hard finish / soft furnishings / already treated), each with a per-band absorption curve; the headline single-value RT60 estimate and the panel budget use a mid-high-band average coefficient, while the 6-band Sabine curve drives the per-band display. The target RT60 is also a 6-band curve (not a single value), matching standards like EBU Tech 3276 / THX that allow a longer low-frequency tail.

2.2 Image Source Method (ISM)

First reflections use mirroring: reflect the source across a wall; the intersection of the “mirror source → listener” segment with the wall is the reflection point.

  • Rectangular room: mirror across all 6 planes (4 walls + floor + ceiling) — closed-form.
  • Non-rectangular (PolygonReflectionCalculator): mirror each wall segment from RoomPolygon.walls() and validate the in-segment parameter s ∈ [0, 1] (floor / ceiling reflections additionally require the reflection XY to lie inside the polygon); first-order paths also get a 2D occlusion check — any source→point→listener leg crossing another wall segment is discarded.

Multi-order reflections (orders 1–3) are implemented by MultiOrderReflectionCalculator following pyroomacoustics' recursive approach:

  1. Enumerate all wall sequences [s₁, …, sₙ], adjacent walls distinct
  2. Image-source iteration: S₀ = source, Sₖ = mirror(Sₖ₋₁, sₖ)
  3. Back-trace from the listener: line Sₙ → listener meets sₙ at Pₙ; line Sₙ₋₁ → Pₙ meets sₙ₋₁ at Pₙ₋₁; recurse back
  4. Each intersection must lie within the wall's valid range and in-segment t ∈ (0, 1)
APPROXIMATION In non-rectangular rooms, multi-order uses the bounding-box approximation (“ghost reflections” near concave corners). The analysis layer discards any path whose reflection point is > 0.3 m from every real wall segment; only the remaining valid paths feed flutter / slapback detection.

2.3 Path energy

Broadband energy as a function of reflection count and distance attenuation:

PATH ENERGY $$ E_{rel} = (1 - \bar{\alpha})^N \cdot \frac{d_{direct}^2}{d_{total}^2} $$
  • α is clamped to [0, 0.99] so α = 1 can't produce log(0) or pow(negative, N) = NaN that poisons the whole path
  • Distance attenuation follows spherical spreading 1/r²
  • Conversion to dB uses 10·log₁₀ (energy domain)

Per-band energy is computed separately for the 6 octave bands (each using the per-band coefficient from finishProfile.absorptionByBand).

2.4 RT60 estimation (measured)

ImpulseResponseEstimator:

  • Matched filtering: the recorded 0.45 s sweep is inverse-filtered (ESS / Farina) into a single impulse, gaining ~+32 dB effective SNR
  • rmsEnvelope: sliding-window RMS envelope
  • Schroeder backward integration: E_back(t) = ∫ₜ^∞ s²(τ) dτ, normalized with 10·log₁₀ to give the EDC curve
  • Slope linear regression: prefer T30 [−5, −35 dB] by SNR, falling back to T20 [−5, −25] or T10 [−5, −15]
  • RT₆₀ = −60 / slope

Per-band RT60: on the matched-filtered samples, band-pass at ISO octave centers (250 / 500 / 1k / 2k / 4k) with Q = √2, then run Schroeder per band.

2.5 RIR simulation

SimulatedRIRGenerator turns multi-order reflection paths into discrete impulse events written to an 8 kHz × 0.5 s PCM buffer:

SimulatedRIRGeneratorpseudocode
for path in reflectionPaths:
    t   = path.totalDistance / speedOfSound
    amp = 10^(path.relativeEnergyDB / 20)
    impulse[round(t * sampleRate)] += amp

Add the direct-sound impulse, then run Schroeder backward integration to extract EDT / RT60 / C50 / C80 / D50.

Before / after comparison Each reflection path is attenuated by the panels accumulated on the walls it touches, producing a “treated” RIR. The UI A/B-toggles to show the EDT / C50 improvement, and AVAudio convolves a dry signal to audibly compare before and after.

2.6 Room modes

Modal frequencies for a rectangular room:

ROOM MODES $$ f_{n_x, n_y, n_z} = \frac{c}{2} \sqrt{\left(\frac{n_x}{L_x}\right)^2 + \left(\frac{n_y}{L_y}\right)^2 + \left(\frac{n_z}{L_z}\right)^2} $$

Classified by how many of nₓ, n_y, n_z are non-zero: 1 = axial, 2 = tangential, 3 = oblique.

Enumeration covers n ≤ 4 per axis up to 300 Hz. Non-rectangular rooms estimate modes from the bounding box (exact modes need FEM); this is stated explicitly in the UI.

§ 03

Geometry system

3.1 Supported shapes

  • Rectangle — width × length × height
  • L-shape — remove an nw × nl notch at a chosen corner
  • U-shape — remove an nw × nl notch at the middle of a chosen wall
  • Custom — any simple polygon (≥ 3, ≤ 24 vertices)

All non-rectangular shapes become a CCW vertex list via RoomPolygon.from(shape:, bboxWidth:, bboxLength:). First-order reflections, area / perimeter and containment math use these vertices directly; an invalid custom polygon (< 3 / > 24 vertices, self-intersecting, near-zero area) silently degrades to the bounding rectangle.

3.2 Polygon geometry utilities

  • Area — Shoelace formula
  • Perimeter — sum of edge lengths
  • contains(x, y) — ray-casting with a boundary epsilon fallback (avoids false negatives at corners)
  • clamped(point) — rectangles use the fast [0, w] × [0, l] path; non-rectangles use the true [minₓ, maxₓ] × [min_y, max_y] of polygon.boundingBox, test polygon.contains, and binary-search toward the bbox center if outside
  • walls() — splits the polygon into wall segments classified by inward normal (auto-binned to leftWall / rightWall / frontWall / backWall), so the rectangular panel recommender can be reused
  • PolygonValidator.isSimple — validates a non-self-intersecting simple polygon

3.3 RoomPlan scanning

RoomCaptureSession (an iOS 16+ API; the app gates scanning at iOS 17) is used, and CapturedRoomMapper maps the scan into a ScannedRoom (wallSegments + openings + furniture). Coordinate mapping:

ARKit (x, y, z) → y is vertical App (x, y, z) → z is vertical Wall endpoints (z=0 floor) App.x = ARKit.x App.y = ARKit.z App.z = 0 Opening / furniture center  App.x = ARKit.x App.y = ARKit.z App.z = ARKit.y

Scans are classified by wall count: exactly 4 wall segments are treated as a rectangle outright; anything else goes through RectangularRoomSimplifier.polygonShapeFromScan, chaining wallSegments into polygon vertices. RoomPlan endpoints err by 3–8 cm typically; a 12-segment loop can accumulate 5 cm, so the closing epsilon is 12 cm. If the chain doesn't close, it falls back to the bounding box and asks the user to rescan.

3.4 AR tape measure

On devices without LiDAR, ARTapeMeasureView (a RealityKit ARView with world tracking) takes over: aim the crosshair at the wall and tap twice to record start / end points, stepping through width → length → height, then confirm to produce a rectangular room — with camera-permission guidance and tracking-failure hints.

3.5 Free-vertex editor

FreeVertexEditorView lets the user drag vertices on a SwiftUI Canvas:

  • Long-press to delete (when vertex count > 3, so ≥ 3 remain)
  • Each drag frame is validated with isSimple; a self-intersecting new position simply isn't applied (the vertex stays put). Tapping empty space inserts a vertex on the nearest edge; there's also a per-vertex numeric editor and a reset-to-bounding-rectangle button
  • 0.05 m grid snapping
  • A max(0.5, bbox × 0.2) m margin outside the bbox lets users drag vertices beyond the current bbox (the parent view auto-grows room.width / length)
§ 04

Listening zones & multi-listener

AcousticConfiguration.listeners supports 1..N listening positions (with 0 listeners a default primary is synthesized with a warning; with 0 speakers the §5.2 use-case templates take over). Analysis runs the image-source method once per listener and merges reflection points into one array (the primary listener's points first). The panel recommender weights each wall by its reflection-point count (cap = max(6, speakerCount × listenerCount)); multiple seats sharing a wall naturally push it toward the budget ceiling.

UI visuals
  • In 3D, secondary listener nodes use opacity 0.45 + scale 0.75
  • In the top view (RoomPlanView), primary reflection points are full-color r=5, secondary 0.45-opacity r=3
  • The reflection list (RecommendationView) shows only primary points; secondary seats get a footnote “+ N reflections already counted in the plan”

Multi-order reflections / RIR / heatmap are still computed once for the primary listener (per-listener O(speakers × surfaces³) is too heavy, and RIR is a single-point metric).

§ 05

Recommendation engine

PanelPlacementRecommender outputs [PanelRecommendation], each with: position (left / right / ceiling / front / back wall / corner), count, priority (essential / strong / recommended / optional), effectivenessMultiplier, targetReflectionPointId.

5.1 Proportional allocation (largest remainder)

The old greedy add() starved walls: left wall consumed the budget → right wall got 0; under tight budgets the ceiling / front / back were starved too. The new version uses the largest-remainder method:

  • Compute each wall's raw desired (reflection-point count + room depth + use case)
  • floor(raw × budget / total) as the initial allocation
  • walls with raw &gt; 0 get at least 1 panel; only when the budget is smaller than the number of walls with demand are surfaces zeroed, lowest-priority first
  • if the sum &gt; budget, trim from the smallest fractional remainder up
  • left/right symmetry pass: if raw[0] == raw[1], force alloc[0] == alloc[1]
  • redistribute any leftover by largest fraction

5.2 Use-case templates

With no speakers placed, use-case-specific templates apply:

  • Meeting room: ceiling 35% + back wall 20% + each side wall 12%
  • Podcast: directly in front of the mic 25% + each side 18% + ceiling 20%
  • Studio: symmetric four-wall damping + up to 4 corner bass traps within the budget (the true budget bypass only happens on the dense low-frequency-mode path of §5.3)
  • General listening: symmetric baseline + a prompt to add speakers

5.3 Physical must-haves bypass the budget

Corner bass traps are forced to 4 when 80–150 Hz modes are dense, bypassing the totalPanel cap. Rationale: corners have the highest sound pressure and bass traps are ~1.5× more efficient there than on walls — a physical necessity, not a budget item.

5.4 Near-listener weighting

A post-pass tags wall recommendations within 1.5 m of a listener as .nearListener, sets effectivenessMultiplier ×1.15, and notes in the title / reason that this is a “close-range absorption-sensitive zone”.

§ 06

Measurement

6.1 ESS sweep

Following Farina's method:

  • 180 Hz → 4 kHz, 0.45 s logarithmic sweep
  • Inverse filter (makeInverseSweep) = time reversal + a +6 dB/octave amplitude envelope (rising with frequency, to counter the ESS pink spectrum)
  • Recording via AVAudioRecorder to lossless Linear PCM (16-bit mono .caf, ~96 dB dynamic range — perceptual codecs don't belong in impulse measurement; the sample rate follows the audio session — 44.1 kHz on the built-in mic, the native 48 kHz on USB measurement mics)
  • Read back → matched filter → analyzeImpulse
Sample-rate alignment The sweep WAV, the inverse sweep and the recording are all generated at the audio session's actual sample rate (makeInverseSweep(sampleRate:) is parameterized) — matched filtering is always in effect, at 44.1 kHz on the built-in mic and 48 kHz on USB measurement mics alike.

6.2 Clap impulse

No playback — the user claps hard once within a 1 s window; 4.5 s are recorded in total (~3.5 s of tail). It goes straight to analyzeImpulse (no matched filter needed; a clap is already a narrow impulse). This covers the 125 Hz octave band, which the sweep (from 180 Hz) misses.

6.3 Measured vs simulated

MeasuredVsSimulatedCard pulls the project's latest measured RT60 and shows the deviation against the current geometry's simulated RT60:

< 15%model and measurement agree
15–30%some divergence (possibly unmodeled furniture / materials)
> 30%large divergence — recheck geometry / materials

6.4 Ambient noise & calibration

AmbientNoiseEstimator records 3 s of silence (trimming 100 ms at each end) for the room's background noise: broadband average / peak dBFS plus a 5-level quietness rating (dBFS is relative-only until an SPL calibration, after which dB SPL is shown). Near-field latency calibration reuses the same ESS sweep: hold the phone within 30 cm of the speaker, take the recording-start-to-impulse-peak delay (including a deliberate 250 ms pre-roll), ideally 2–3 runs — a spread of the last 3 readings under 5 ms rates high confidence, under 20 ms medium. The latency is bound to the current input + output route and auto-invalidates when either changes; far-field measurements show the propagation delay with it subtracted.

6.5 USB measurement mics & output routing

The measurement page's input picker lists the built-in mic and class-compliant USB inputs (e.g. miniDSP UMIK-1 / UMIK-2); a routeChangeNotification listener falls back to the built-in mic when the external one vanishes. Recording and sweep sample rates follow the session (UMIK-1 = 48 kHz). REW .txt calibration files import directly (parsing Sens Factor / SERNO, linear interpolation in the log-frequency domain, endpoint clamp; the 90° file is recommended for room measurement) — the correction only offsets the per-band level display, never the RT60 decay slope.

One-time SPL calibration: fit a sound-level calibrator (default 94 dB @ 1 kHz) over the mic, the app samples 3 s of level to derive the dBFS → SPL offset, and readings then show true dB SPL. The calibration (curve + offset) is bound to the input port UID and persisted to ~/Library/Application Support/mic_calibration.json; switch inputs and the UI warns in orange that the calibration doesn't apply — re-import and re-calibrate.

Playback goes through the system route picker (an AVRoutePickerView wrapper, placed on the sweep / near-field calibration cards); the session allows AirPlay / Bluetooth A2DP / HFP so test tones can play through external speakers, and falls back to the speaker when none is connected. The recording input is always explicitly pinned to the user's choice (USB mic or built-in) so a Bluetooth device's mic can't steal the input mid-playback and contaminate the measurement.

§ 07

Visualization & output

7.1 3D view

Room3DController renders with SceneKit: walls / floor / ceiling / speaker nodes (thin cabinet + accent tick + channel pill) / listener (FE glyph + drop stem) / obstacles (translucent SCNBox) / recommended panels (per-wall panel mockups) / multi-order reflection path segments / reflection-energy heatmap overlay.

DESIGN · TECHNICAL DRAWING The look is a “technical-drawing style” (hairline edges, constant lighting, no PBR), deliberately distinct from photorealistic 3D.

7.2 Reflection-energy heatmap

ReflectionEnergyMapGenerator accumulates each multi-order path's energy onto the touched wall's uCells × vCells grid (default 12×6). One energy map per wall, overlaid in 3D as an energy color layer (blue → cyan → green → yellow → red).

EnergyHeatmapRenderer turns the grid into a UIImage: block-style cells at 16 px each (no interpolation between cells, keeping grid edges legible; on-wall smoothing comes from SceneKit's bilinear texture filtering) + a custom 5-stop ramp (blue → cyan → green → yellow → red) + alpha mapped linearly from 0.20 to 0.85 by the energy's position on a fixed −30…0 dB scale (−∞ fully transparent).

7.3 RIR convolution preview

RIRPreviewPlayer uses AVAudioEngine + AVAudioPlayerNode to convolve a procedurally synthesized dry signal (plucked string / clap burst / vowel-like speech — no bundled samples) with the simulated RIR and play it, so users hear the difference “untreated vs treated”.

Time-domain convolution RIRConvolver.convolve(dry:rir:) is direct O(N × M) (N = dry samples, M = RIR samples), peak-normalized to 0.9 to avoid clipping.

7.4 PDF installation manual

ReportExporter renders an A4 PDF (595 × 842 pt) via UIGraphicsPDFRenderer:

  • Room overview + 3D snapshot
  • 6-band RT60 Sabine-estimate-vs-target bar chart (measured RT60 doesn't enter the PDF)
  • Reflection-point table + mode table + risk summary
  • Recommended-panel table (the shopping-list BOM is shown in-app, not in the PDF)
  • Geist font + PingFangSC fallback (CoreText cascadeList, so CJK doesn't render as □□□)

7.5 Project persistence

ProjectStore (an actor) writes ~/Library/Application Support/Acouvero/projects.json: [RoomProject], each holding the full Room + speakers + listeners + recommendations + panelCountOverrides. On first launch, data migrates automatically from the legacy FindEcho/ directory (FindEcho is the pre-rename internal codename, deliberately kept in code symbols).

MeasurementStore (an actor) writes ~/Library/Application Support/Acouvero/measurements.json: [MeasurementResult], linked to a project by roomId.

Both use element-level decode recovery (a Throwable<T> wrapper) — one corrupt record won't fail the whole list.

MicCalibrationStore writes ~/Library/Application Support/mic_calibration.json (directory root, no subdirectory): the REW calibration curve + SPL offset, bound to the input port UID — switching inputs requires a fresh import / calibration.

§ 08

Engineering notes

8.1 SwiftUI + @Observable

The ViewModel (FindEchoViewModel) uses Swift 5.9's @Observable macro — state fields are observed directly, no Combine. AcousticAnalysis results are cached by an Equatable comparison of AcousticConfiguration:

FindEchoViewModel.analysisswift
var analysis: AcousticAnalysis {
    let cfg = configuration
    if let cached = cachedAnalysis, cachedConfiguration == cfg {
        return cached
    }
    let result = analysisService.analyze(cfg)
    cachedConfiguration = cfg
    cachedAnalysis = result
    return result
}

8.2 Actor isolation

ProjectStore and MeasurementStore are actors, serializing IO / async logic naturally; RoomPlanService is a @MainActor class. UI callers use async / await, so the main thread is never blocked by disk IO.

8.3 AVAudio lifecycle

AudioSessionManager centralizes the session category (.playback for RIR preview, .playAndRecord for measurement). Interruption protection covers both paths: on the measurement side an InterruptionMonitor inside AudioMeasurementService listens for AVAudioSession.interruptionNotification — a call / Siri during a sweep, clap or ambient-noise run discards the result with an error; on the preview side RIRPreviewPlayer listens too, auto-stopping the player node and clearing isPlaying. An audio-route change mid-measurement also voids the run.

8.4 Task cancellation

Convolution playback is a background task; rapid button taps trigger pendingPlayTask?.cancel() plus a Task.isCancelled double-check to prevent out-of-order MainActor writes.

8.5 Test coverage

AllShapesAndUseCasesTests uses the Swift Testing framework to run the full matrix — every shape × every use case (single listener; speakers are a stereo pair or a single center per use case) — asserting:

  • analysis.reflectionPaths.count >= 1
  • analysis.warnings includes the approximation note for non-rectangular rooms
  • the recommended panel total is non-negative (totalPanelCount ≥ 0; no budget-match assertion)
  • self-intersecting custom polygons fall back safely (no crash, degrade to a rectangle)
  • vertices &lt; 3 and other edge-case fallbacks

8.6 Platform & localization

Deployment target iOS 17.0, iPhone + iPad; zero third-party dependencies (no SPM / CocoaPods packages) — system frameworks only (SwiftUI / SceneKit / ARKit / RoomPlan / AVFoundation / Accelerate). Localization uses an .xcstrings catalog: Chinese as the source language plus English / Français / Deutsch / Español / Português (Brasil) — 6 languages total, switchable in-app or following the system.

§ 09

Known limitations

  1. Multi-order in non-rectangular rooms uses the bounding box — 2nd/3rd-order flutter / slapback risk near concave corners may be off. First-order points themselves use the true polygon.
  2. Room modes in non-rectangular rooms are estimated from the bounding box — exact modes need FEM.
  3. iPhone built-in mic measurement — uncalibrated; non-flat frequency response / sensitivity. Confidence is always marked .low, used as a cross-reference against the geometric estimate. With a USB measurement mic (miniDSP UMIK-1 / UMIK-2) and its REW calibration file loaded, per-band levels are corrected by the calibration curve; a one-time 94 dB SPL reference calibration then shows ambient and measurement levels as absolute dB SPL (dBFS only until then).
  4. Single-source ISM assumption — ignores cabinet diffraction, horn directivity and other real speaker traits.
  5. No bass-trap modeling — sub-80 Hz problems fall outside the Sabine model and need dedicated low-frequency treatment.
  6. AR scan accuracy — RoomPlan endpoints err 3-8 cm typically; a 12-segment loop can accumulate 5+ cm. Wall thickness isn't modeled.
§ 10

References

  1. Sabine, W. C. (1922). Collected Papers on Acoustics. Harvard University Press.
  2. Allen, J. B., & Berkley, D. A. (1979). Image method for efficiently simulating small-room acoustics. JASA, 65(4), 943–950.
  3. Schroeder, M. R. (1965). New method of measuring reverberation time. JASA, 37(3), 409–412.
  4. Farina, A. (2000). Simultaneous measurement of impulse response and distortion with a swept-sine technique. Audio Engineering Society Convention 108.
  5. Scheibler, R., Bezzam, E., & Dokmanić, I. (2018). Pyroomacoustics: A Python package for audio room simulation and array processing algorithms. ICASSP 2018.
  6. EBU Tech 3276. Listening conditions for the assessment of sound programme material.
  7. ITU-R BS.1116. Methods for the subjective assessment of small impairments in audio systems.
  8. BB93 — Acoustic design of schools (UK).
  9. THX Pro Cinema Certification requirements.
Want the plain-language version?

“Why your room rewrites the music you hear”

Read the Deep Read