Skip to content

Repository files navigation

EV Engine Sound Sonification

A real-time synthesis engine that turns live vehicle telemetry into an interior engine sound for electric vehicles. It reads telemetry from the OBD-II port, streams it over OSC, and uses it to drive a hybrid wavetable/granular engine in Max MSP, tested on the road.

Matteo Caruso Linardon (Lead research & DSP Development). Road testing conducted with Luca Piscanec, who provided the test vehicle and drove every recorded session.

License: CC BY-NC-SA 4.0

▶ Watch the road test on YouTube. The live OBD-II to OSC stream driving the Max MSP engine in a moving car.

Road test video on YouTube

Measured across 18 recorded drives, a new telemetry frame arrives every 96 ms, while the DSP needs a value every 1.33 ms. Roughly 72 audio blocks elapse between one frame and the next, and occasionally 191. The engineering problem was reconstructing a continuous, believable control signal from that, and deciding what the sound should do when a frame is simply late.

What this project demonstrates

A quick map of what's in here, and where the numbers come from:

  • Telemetry arrives every 96 ms, but the DSP needs a new value every 1.33 ms. That gap, 72 audio blocks measured across 22,367 recorded frames, turned out to be the real engineering problem here, more than the synthesis itself.
  • The pitch dropouts inside the recordings sit on an exact 128-frame grid, which is what separates an acquisition-stack artefact from the far more common, and unrelated, gear-change behaviour of the test car.
  • The three-parameter limit isn't arbitrary: a fourth OBD-II request would cost 16% of the live frame rate, which is also why reverse gear never got its own sound.
  • The granular layer runs 36 voices, the lowest count that still produced the intended texture, and it stayed there because voice count seemed likely to matter on more constrained hardware. The profiling section below tries to say where that assumption still hasn't been tested.
  • Every telemetry figure in this document can be regenerated from analyze_telemetry.py, using the standard library only.
  • A conservatory examination panel listened to the system from inside the moving car, and this document tries to be honest about why that still isn't a user study.

Scope. This is an interior, driver-facing sound. It is not a homologated AVAS: UN R138 and FMVSS 141 regulate exterior warning sound for pedestrian safety, and no claim of compliance with either is made here. The prototype was developed on a combustion test vehicle, whose telemetry stands in for an EV's (see Known Limits & Scope).

Design Intent

A combustion engine tells the driver things no instrument does. Its pitch and its roughness are a continuous, ambient report on speed and effort, and drivers act on that report without ever consciously consulting it: shifting gear, easing off, moderating consumption when the engine audibly strains. An electric vehicle removes the channel entirely.

The goal here is to give it back. The sound should let a driver estimate how fast they are moving and how hard the car is working, peripherally, in the same way engine noise already does, and in doing so keep them connected to the machine rather than insulated from it. Single-speed EVs offer no gear change for that feedback to prompt; multi-speed electric drivetrains, currently being explored, would give it a concrete task again.

Every synthesis decision follows from this. Speed and effort have to be legible as separate sensations, which is why they are mapped to independent perceptual axes rather than sharing one. And the result has to be heard as a single source (the voice of the car) rather than as the layers of a synthesiser, which is what the entire FX chain exists to enforce.

This is a design position, not a validated finding. No user study was run, and no claim is made that the system measurably improves driving behaviour. What has been tested is that it works on the road; the rationale above is the intent it was built to serve.


Table of Contents


System Data Flow

flowchart TD
    A["Vehicle ECU"] -->|OBD-II protocol| B["vLinker Adapter\n(USB Serial)"]
    CSV["CSV Test Files\ndata-tools/CSV_files/"] -->|play_data.py| OSC
    B -->|"live_data.py\nlive_data_universal.py"| OSC["OSC /car · UDP 127.0.0.1:5005\n[rpm, load, speed]\n~20 Hz live · 10.4 Hz replay"]

    OSC --> SYNTH

    subgraph SYNTH ["Max MSP Synthesis Engine"]
        direction TB
        WT["Wavetable Module\n(Melodic Core)"]
        GR["Granular Module\n(Organic Texture)"]
        FX["FX Matrix\n(Spectral & Spatial Cohesion)"]
        WT --> FX
        GR --> FX
    end

    FX --> OUT["Audio Output"]
Loading

Engineering for Real Time

The constraint: telemetry arrives 72 audio blocks apart

OBD-II is a request/response protocol: the bridge asks for one parameter and waits for the ECU to answer. Nothing is pushed. Measured over 22,367 frames across 18 drives (35.8 minutes of telemetry):

measured
Median frame interval 96 ms → 10.4 Hz effective
Standard deviation 2.5 ms
99th percentile 98 ms
Worst case 254 ms (2.65× the median)
Frames later than 100 ms 162 (0.72%)
Frames later than 150 ms 9 (0.04%)
Audio blocks between frames (64 smp @ 48 kHz) median 72, worst 191

Two things stand out here. The link is more stable than you'd expect from a polled serial protocol: a 2.5 ms deviation on a 96 ms period is tight, though the rare outliers run 2.65× the median, so any interpolation scheme has to survive a frame arriving nearly three times late. More importantly, the audio engine runs roughly seventy times faster than its own input. Latency in this system isn't really a DSP problem; it's almost entirely down to the acquisition path.

The 96 ms figure comes from the protocol itself, not from anything specific to the vehicle. rec_data.py issues three blocking queries and then sleeps a fixed 50 ms, so the excess over that sleep is the protocol cost: 15.3 ms per PID round-trip.

Why only three parameters

Why does the bridge target 50 ms at all? Because that's roughly the floor the protocol imposes: three sequential PID round-trips take about 46 ms, so 50 ms is the fastest round number this link can sustain. One consequence: the whole parameter budget was fixed before a single mapping was designed.

The engine is driven by three values: RPM, engine load and speed. Because queries are sequential and blocking, every additional parameter costs a full round-trip, paid on every frame:

PIDs requested Live bridge Recorder
3 (current) 20.0 Hz 10.4 Hz
4 16.3 Hz 9.0 Hz
5 13.0 Hz 7.9 Hz
6 10.9 Hz 7.0 Hz

A fourth parameter costs 16% of the live frame rate. What that buys in mapping richness, it takes back in resolution on the three signals that actually carry the sound. Three turned out to be the point where the trade stopped paying off.

The live bridge holds 20 Hz only because it subtracts query time from its 50 ms target (wait_time = max(0, 0.05 - elapsed)). With three PIDs costing ~46 ms, that leaves about 4 ms of headroom: a fourth parameter wouldn't degrade the rate gracefully, it would blow the deadline outright.

The three values also travel as a single OSC message (/car carrying three arguments), which keeps each frame atomic: Max never receives an update in which RPM has advanced but speed hasn't, which would briefly drive the wavetable and granular layers from two different moments in time.

Two signals, opposite failure modes

The three inputs don't misbehave in the same way, so they can't be smoothed in the same way.

Signal Distinct values Frames that change p99 jump Max jump
RPM 2078 94.8% 183 1959
Engine load 234 41.8% 30.6 89.4
Speed 121 18.0% 1.0 54.0

Speed is a staircase. It arrives quantised to integer km/h and changes on fewer than one frame in five. At steady cruise it holds a single value for a mean of 5-8 frames and, in the worst case measured, 48 consecutive frames (4.6 seconds frozen):

Run Distinct values Longest hold
03_CAL_Cruising_30kmh 4 48 frames (4.6 s)
04_CAL_Cruising_50kmh 13 45 frames (4.3 s)
05_CAL_Cruising_90kmh 13 22 frames (2.1 s)
14_Highway_max_speed 49 30 frames (2.9 s)

This is what actually justifies interpolating the control signals. Speed drives the granular buffer read position at 1 s ≡ 10 km/h, so a single 1 km/h increment is an instantaneous 0.1 s jump in the buffer, a discontinuity in timbre, not just in amplitude. Applied raw, a steady cruise would produce a sound that sits perfectly still for seconds and then lurches. Continuous inputs are therefore ramped rather than stepped: the patch uses 22 line / line~ objects to glide between successive values instead of jumping when a new frame lands.

RPM is the opposite problem. It changes on 94.8% of frames and is never still, but its 99th-percentile jump is 183 RPM and its worst is 1959 RPM in a single frame. Here smoothing isn't about filling silence, it's about absorbing spikes without dulling the response that makes acceleration feel immediate.

What happens when a frame is lost

When a value arrives as zero, extract_value() passes it straight through. That's also what gets substituted when the ECU returns nothing at all, so either way the synthesiser receives not a missing value but an assertion that the engine has stopped: pitch collapses for the duration of the frame, then recovers. For a sound whose entire job is to track engine state, that's about the worst artefact you could have.

Two unrelated things produce it, and only one of them is visible in the recorded data.

On the road, zeros arrive at every gear change. Driving the combustion test vehicle live, the reported value drops to zero each time the car shifts and the sound dives with it. This is the routine case, not an edge case, and it's what's audible in the road test video. It was deliberately never fixed: the target vehicle class is single-speed, and an EV with no gearbox never produces it. That reasoning is examined in Known Limits & Scope.

In the recorded corpus, zeros are rare and strictly periodic. The 18 recordings contain 43 frames reporting zero RPM while the vehicle is moving (physically impossible, so unambiguously lost responses), and they carry a signature that has nothing to do with driving at all:

  • every event is exactly one frame long, never two consecutive;
  • the value returns to within ~3% of its previous reading on the very next frame;
  • RPM, speed and engine load are never zero in the same frame: each event hits one parameter only;
  • events appear only after roughly 4.5 minutes of continuous session, so only the two longest runs contain any;
  • every interval between events is an exact multiple of 128 frames (128, 256, 384, 512).

A strict 128-frame grid isn't driving behaviour, so this second population looks like an artefact of the acquisition stack rather than a vehicle event. What the recordings can't settle is why zeros are so much rarer here than on the road. The recorded path polls at 10.4 Hz while the live bridge polls at 20 Hz, and no live session was ever logged with timestamps to compare the two. So the relationship between the two populations is left stated as unresolved here, rather than guessed at.

What it costs

Measured on a MacBook Pro (M4 Pro, 24 GB, mains power), macOS 26.5.2, Max at 48 kHz, Overdrive off, Scheduler in Audio Interrupt off, CoreAudio built-in output. Figures are Max's own DSP reading (the fraction of the audio callback deadline consumed, not system-wide CPU). Stimulus is 14_Highway_max_speed, the heaviest run in the corpus (76.2% mean engine load, 65% of frames above 80%, highest mean RPM), replayed through play_data.py. Three repetitions per configuration, sampled at 4 Hz via adstatus cpu.

poly~ voices Vector Mean CPU Peak CPU
DSP on, no telemetry 64 1.00% 1%
12 64 1.00% 1%
24 64 1.09% 2%
36 (shipping) 64 2.00% 2%
48 64 2.00% 2%
64 64 3.00% 3%
36 256 2.90% 3%

On this hardware, CPU isn't the constraint. Cost grows linearly with allocated voices at roughly 0.04 points per voice (about one percentage point per 25 voices), with no knee anywhere in the range tested: a linear model through the endpoints predicts 1.46% at 24 voices and 2.38% at 48, and both round to exactly what the meter reported. Running 64 voices instead of 36 would have cost one additional percentage point, a saving that's invisible on a machine this fast. That doesn't retire the reasoning behind the 36-voice floor so much as relocate it: the count was set by ear and held there for hardware this measurement can't speak for. What the benchmark does establish is the shape of the cost: linear in allocated voices, with no threshold effect to exploit.

The larger buffer is strictly worse. Raising the vector from 64 to 256 samples increased CPU from 2.00% to 2.90% while quadrupling output latency (1.33 ms to 5.33 ms at 48 kHz). The likely mechanism is architectural: a poly~ voice is computed for a whole vector once it's active, so the quantum of the just-in-time activation is the vector size, and short grains waste proportionally more of a long block. This patch is cheaper at low latency, which inverts the usual trade-off; there's no configuration in which 256 is worth choosing. That said, this is one comparison, not a controlled isolation of the mechanism.

Over a 13-minute run (17_ENV_Suburban, 36 voices, vector 64) the mean was 2.13% and the peak 3%, no drift and no accumulation across a realistic session.

The limiting factor is the instrument, not the patch. Max reports DSP load quantised to roughly 1%, and the whole experiment spans three steps of it. A configuration reading a flat "2.00%" means every sample read exactly 2, not that the true value is 2.000. Repeatability was excellent (0.13 points of spread across the 24-voice triplet, 0.04 across the 256-vector triplet), so the trend is trustworthy, but nothing finer than "linear, ~0.04 points per voice" is really supportable from this data.

Reproducing these measurements

Every telemetry figure above is produced by data-tools/analyze_telemetry.py from the CSV files in this repository, using the standard library only. No vehicle and no virtual environment required:

cd data-tools
python analyze_telemetry.py

The CPU figures are reproducible from the configuration stated above but aren't scripted, and end-to-end latency was never instrumented. The acquisition path dominates it by roughly two orders of magnitude, so it wasn't the useful thing to measure. The timing and CPU claims are measurements; the perceptual claims below are the judgement of the developer and the driver, and are labelled as such.


MAX/MSP Patch

The engine runs two synthesis methods in parallel because neither could carry the sound alone. The wavetable core provides pitched, harmonic identity under precise control; the granular layer provides the roughness and unpredictability that keeps it from sounding synthetic. Fusing them into one perceived source is the job of the FX matrix.

engineV1.maxpat holds 469 objects, but the top level holds only three: the whole engine is delegated to three module subpatchers, each self-contained and separately testable against replayed telemetry. Every stage is stereo: each subpatcher below is two-in, two-out.

flowchart TD
    OSC["OSC /car\n[rpm, load, speed]"]

    subgraph WT ["p Wavetable_modules"]
        direction TB
        BL["p buffers_load"]
        M1["p module_1\nEngine A · dyad"]
        M2["p module_2\nEngine B · tetrad"]
        BL --> M1
        BL --> M2
    end

    subgraph GR ["p granular_module"]
        POLY["poly~ granular 36\n(granular.maxpat)"]
    end

    subgraph FX ["p FX"]
        direction TB
        OD["overdrive~"]
        RES["p Physical_Resonators"]
        ER["p early_reflections"]
        OD --> RES
        RES --> ER
    end

    OSC --> WT
    OSC --> GR
    WT --> FX
    GR --> FX
    FX --> OUT["Audio Output"]
Loading

The voice count isn't just documented, it's built into the patch itself: poly~ granular 36 carries it as an object argument, and the granular voice itself lives in granular.maxpat as a separate 41-object file.

On the perceptual language below. Words like hollowness, tension and urgency describe what each mapping is intended to produce, and what two listeners heard on the road. They're design rationale, not measured psychoacoustics, and not user-tested (see Known Limits & Scope). The measured claims in this document are confined to timing and CPU.

What was tried first

FM synthesis was rejected on timbre. It reached inharmonic, metallic territory too readily, and its results were simultaneously predictable and hard to steer, the opposite of the organic, slightly rustic character the project is after. Spectral richness was never the problem; malleability was.

Physical modelling was prototyped and abandoned. It was expensive, but that's not actually why it was dropped: the deciding factor was robustness. A physical model's stability depends on its inputs staying inside the range it was tuned for, and this system's input is a polled telemetry stream that delivers 1959 RPM jumps between consecutive frames and occasional hard zeros. A synthesis method that unexpected values can drive into unexpected states is the wrong choice when the input is known to misbehave.

Wavetable and granular synthesis both fail more gracefully by comparison: a bad value produces a wrong sound, never an unstable one. Choosing a method that degrades predictably followed directly from what the telemetry measurements showed.

Wavetable Module (Melodic Core)

Wavetable synthesis was chosen for the harmonic layer because it builds chordal material efficiently and exposes exactly two parameters that matter most here, independently controllable: pitch of the chord, driven by RPM, and brightness, driven by engine load through the scan position in the table. Effort and speed therefore move on separate perceptual axes instead of fighting over one.

Two engines run on distinct lookup tables, interpolated in real time:

  • Engine A (dyad): at low speed, a stable harmonic anchor built on a major third. As velocity rises the interval opens to a perfect fifth, an emptier sonority meant to withhold resolution as the car accelerates.
  • Engine B (tetrad): an independent, spectrally richer table configured as a major seventh. Above 100 km/h the chord becomes a minor seventh, meant to raise inner-harmonic tension and a sense of urgency.

Each engine is a detuned stereo pair. Every wavetable is instantiated twice, the two copies panned slightly left and right of centre and detuned against one another. The detune amount is driven by engine load, which makes the beating between the pair the primary carrier of mechanical effort: the patch maps it as scale 0. 100. 1. 8, so load rising from idle to full takes the sound from roughly 1 Hz of slow shimmer to 8 Hz of agitation. Effort therefore arrives as three simultaneous cues: beat rate, brightness, and the widening of the stereo image as the pair drifts apart.

The engine is stereo end to end: every module in the signal path is two-in and two-out, from the wavetable and granular modules through the FX chain to dac~.

Telemetry mapping:

  • RPM maps to three destinations at once: the wavetable read position, the fundamental frequency, and the balance of upper partials against the fundamental (low RPM favours f0 for a dark, warm timbre, high RPM introduces brilliance). The frequency mapping was tuned by ear so the wavetable and granular layers rise and fall together rather than drifting apart in pitch; no spectral measurement was taken to verify the alignment.
  • Engine load sets the detune between the two copies of each wavetable, and with it the beat rate (1-8 Hz), as well as the scan position in the table (scale 0. 100. 0. 0.6) that governs brightness.
  • Speed crossfades Engine A into Engine B: low speeds keep the foundational dyad dominant, high speeds shift exposure toward the denser, unresolved tetrad.

Granular Module (Organic Micro-Synthesis)

The granular layer supplies friction, mechanical breath and roughness, the unpredictable, slightly rustic quality of a combustion engine that a purely wavetable EV sound can't produce on its own.

  • Voice allocation: 36 parallel instances of a custom granular voice in poly~. 36 is the lowest count that still produced the intended texture density; above it, nothing more is gained. Holding the count at that floor rather than allocating more was deliberate, on the reasoning that voice count should matter on the constrained hardware this would eventually have to run on. Desktop profiling can neither confirm nor refute that: on an M4 Pro the gap between 36 and 64 voices is about one percentage point, small enough that the saving is invisible there. The reasoning is a working assumption about embedded targets, not a demonstrated saving. Voices are computed and unmuted only for the duration of a grain's window and muted immediately on termination; the vector-size result suggests that activation is doing real work, since its effectiveness degrades measurably as the block grows.
  • Buffer mapping: grains are read from a 22-second stereo reference buffer (Audio_files/granular.wav, 22.0 s) at a scale of 1 s ≡ 10 km/h, so timbre tracks speed proportionally. 22 seconds spans 220 km/h, headroom sized for the class of vehicle this is aimed at rather than for the test car, which peaked at 122 km/h.
    • Low speed: grains are restricted to the early buffer, harmonically rich, smooth and warm.
    • High speed: the read window shifts to the later buffer, which is inharmonic, dense and physically rough.
  • Parameter windowing: each voice draws from a stochastic boundary window updated by the car's speed, so the micro-structure never repeats identically.
  • Stereo placement: every grain is assigned a random position in the stereo field the moment it's triggered, chosen per grain and mapped to nothing. This is the opposite use of stereo from the wavetable layer, where width is information. Here it's decorrelation: 36 voices sharing a centred image would sum into a narrow, correlated point source, while scattering them keeps the texture surrounding and stops the granular layer from collapsing into the middle of the wavetable chord.
  • Telemetry mapping: RPM controls grain playback speed and transposition ratio, keeping the texture tuned to the wavetable core. Engine load governs trigger density and grain duration (low load gives sparse, wide, overlapping grains and a smooth envelope; high load switches to dense, ultra-short grains and acoustic urgency).

FX Matrix (Spectral & Spatial Cohesion)

The FX stage exists for a single reason: two independent synthesis methods need to be heard as one sound source, not two. If the listener resolves them into separate voices, the illusion fails, the car stops having a voice and starts having a soundtrack. This is also why the wavetable and granular layers are locked to a constant frequency ratio rather than left to drift independently: shared pitch motion is the first condition for perceptual fusion, and the FX chain enforces the rest.

  1. Overdrive: two instances of Max's stock overdrive~. The point is that both engines are saturated identically, so the same added harmonics appear in both and give the ear a shared cue to group them. The patch comment states the intent plainly: "saturation to generate identical harmonics to both modules to glue them together."
  2. Resonant filterbank: a multi-channel filter array standing in for the cavity resonances of a physical chassis. Forcing both engines through identical static formant peaks gives them a shared structural signature.
  3. Early reflections network: a short stereo tapped delay line matrix that places the result in a small, concrete space, so the sound reads as belonging to the cabin rather than to the playback system.

Validation

The 18 recorded scenarios

Every drive was recorded to CSV before any tuning was done, with the goal of building a reproducible iteration loop. Telemetry replayed through play_data.py reaches Max at its original timing, so a change to the patch can be judged against the exact same 4.6-second speed freeze or the exact same load spike that exposed the problem, without having to book a car, a driver and a road. The 18 runs total 35.8 minutes of telemetry and cover calibration, dynamics, stress and environment cases.

What the recordings revealed

Reverse gear is invisible. In reverse, speed is reported as a positive number: nothing in the three-parameter stream distinguishes backing up from moving forward. A dedicated reverse sound would have required a fourth PID plus additional logic to infer gear state; the first costs 16% of the frame rate (see Why only three parameters), the second costs latency on every frame. The feature was dropped rather than pay for both.

Gear changes zero the telemetry. On the combustion test vehicle the reported value collapses to zero at every shift and the sound dives with it (audible in the demo video). It's the most conspicuous artefact in the system, and the one deliberately left alone, since the target vehicle class has no gearbox to produce it.

The dropouts inside the recordings are a different phenomenon. The zeros that appear in the CSV corpus fall on an exact 128-frame grid, which marks them as separate from the gear-change zeros above. It was only visible because the runs carry timestamps; from the driver's seat the two are indistinguishable.

What only the road revealed

Two things didn't show up in replay, and both changed the patch:

  • Silence at a standstill was exhausting. With the vehicle stopped, the sound continued at full level. Over a session this was fatiguing for both the developer and the driver, so a level reduction on standstill was added.
  • The car's speakers are not studio monitors. The frequency response of the vehicle's audio system flattened exactly the band where engine effort was legible, and the load mapping that read clearly on headphones barely registered in the cabin. The timbre was rebalanced brighter to compensate.

Evaluated in context

The project was submitted for a conservatory examination in electronic music. The examining panel, three faculty members of the electronic music department, were driven for 15-20 minutes with the system running live in the cabin, across ordinary mixed driving.

The assessment was positive and specifically endorsed the informative intent: the panel reported that the sound conveyed speed and effort as it was designed to, which is the design premise of the entire project.

That's a real evaluation in the actual deployment context, not a desk demo, and it counts for something. But it has real limits too: the panel were passengers, not drivers, so they never performed the driving task the feedback is meant to support. There were only three of them, there was no task and no control condition, and they were grading the work at the same time as judging it.

Coverage, and where it is thin

The 18 runs are honest about what they don't cover:

  • Speed range is capped by law, not by caution. Only 1 of 18 runs (14_Highway_max_speed) ever crosses 100 km/h, so the major-seventh → minor-seventh transition rests on a single recording: 31 seconds above the threshold, peaking at 122 km/h as reported by the ECU (130 km/h indicated on the dashboard, the Italian motorway limit). The two figures are consistent, since speedometers are required to over-read; the ECU value is the true one, and it's the one the synthesis engine actually receives. Public roads can't legally yield more coverage than this, so anything above it requires a closed circuit.
  • One vehicle. All 22,367 frames come from a single combustion test car. Frame rate, PID latency and value ranges will differ on other vehicles.
  • CPU was measured on one machine, and a fast one. 1-3% of the audio deadline on an M4 Pro says little about an automotive head unit, where the same patch could plausibly land an order of magnitude higher. The voice-count headroom demonstrated above is headroom on a laptop; on an embedded target the 36-voice limit may well turn out to be the economic constraint it was originally assumed to be. Nothing here has been profiled on deployment-class hardware.
  • Perceptual judgements are not instrumented. All timbral decisions are by ear, by two listeners. End-to-end latency was never measured either, though the road test video is unedited, so the delay between a throttle input and the sound responding is visible there in real time, and it reads as immediate.
  • No user study. The premise that this feedback helps a driver estimate speed and effort rests on first-hand report: the driver, Luca Piscanec, confirmed at the wheel that he could hear engine effort as intended, and an examination panel riding in the car endorsed the same thing. None of that is a user study: no task, no control condition, no participants beyond the people who built it and the people grading it. The premise is supported by informed first-hand report, and remains untested.

Known Limits & Scope

Worth stating plainly, since these are deliberate choices rather than oversights:

  • Not an AVAS. Interior, driver-facing sound only. No claim of conformity to UN R138 or FMVSS 141, which govern exterior pedestrian-warning sound.
  • No reverse sound. Cut for the bandwidth and latency reasons above. On a vehicle that reports gear state cheaply, this becomes worth revisiting.
  • The zero-value pitch artefact is unpatched, on purpose. Its dominant cause is the gearbox of a combustion test mule, and the target (single-speed EVs) has no gear change to produce it. Guarding against something the deployment vehicle can't do would mean defending the prototype rather than the product. The reasoning is deliberate, and it has a known edge: it doesn't cover the rarer 128-frame periodic dropouts, which come from the acquisition stack and aren't vehicle-specific. Those remain open, and are documented here.
  • Mapping ranges are vehicle-specific. RPM ranges, load reporting and speed resolution vary between vehicles, so the mappings need rescaling per target car. The test vehicle was the only one available.
  • Recorded and live rates differ. rec_data.py sleeps a fixed 50 ms after its queries (period = query + 50 ms ≈ 96 ms); live_data.py sleeps the remainder of a 50 ms target (period = max(query, 50 ms) = 50 ms). Replay is therefore faithful to the recordings but drives the live vehicle at roughly half rate. The one-line fix, a shared target period in both scripts, is deliberately not applied: aligning up to 50 ms would invalidate all 18 recordings and needs the car back. The current asymmetry is arguably the less bad option anyway: with ~4 ms of headroom the live loop drifts between 50 and 90+ ms while the recorder holds 96 ms to within 2.5 ms, and for a control signal whose ramp times track the expected frame interval, a stable period beats a fast one.

What Comes Next

In rough order of cost-to-value, and every item traceable to something measured above:

  1. Make the frame period an explicit shared constant. A single TARGET_PERIOD compensated in both rec_data.py and live_data.py closes the 10.4 / 20 Hz split. Cheap in code, but it invalidates the existing corpus, so it belongs with the next recording session rather than before it.
  2. A calibration pass for a second vehicle. The mappings assume this car's RPM range, load reporting and speed resolution. Establishing what has to be rescaled, and what can stay fixed, is the difference between a prototype and something portable.
  3. Close the coverage gap above the motorway limit. Public-road testing is already at its legal ceiling, so the band above 122 km/h is unreachable without a closed circuit, and the major-seventh → minor-seventh transition still rests on one recording out of eighteen.
  4. Profile on deployment-class hardware. 1-3% on an M4 Pro predicts nothing about an automotive head unit, and the voice-count headroom may not survive the move.
  5. Test the premise with drivers from outside the project. Everyone who has driven with the system so far either built it or graded it.

That's the design and engineering side of things. Everything below is operational reference: installation, usage, data format and troubleshooting.

Prerequisites

Before installation, make sure you have:

  • Hardware: Vehicle with OBD-II diagnostics support + vLinker serial adapter
  • Software:
    • Python 3.7 or later
    • Max 8 or later (for audio synthesis patches)
  • Connectivity: USB serial port available (e.g., COM8 on Windows, /dev/ttyUSB0 on macOS/Linux)

Installation

macOS

bash setup/install_mac.sh

Windows

setup\install_windows.bat

The installer will:

  • Create a Python virtual environment at .venv/
  • Install dependencies from setup/requirements.txt
  • Remove macOS metadata files (.DS_Store) to keep the repository clean

Project Structure

Core Operational Files (Root):

  • engineV1.maxpat: primary audio synthesis engine with hybrid wavetable and effects processing
  • granular.maxpat: granular synthesis module for textured soundscapes
  • live_data.py: real-time OBD-II to OSC bridge (connects vehicle to Max MSP)
  • live_data_universal.py: universal OBD-II to OSC bridge with automatic protocol and serial port detection
  • Wavetables/: audio assets for wavetable synthesis layers
  • Audio_files/: granular synthesis source material

Setup & Configuration (setup/):

  • requirements.txt: Python package dependencies
  • install_mac.sh: automated macOS setup script
  • install_windows.bat: automated Windows setup script

Data Tools & Algorithm Development (data-tools/):

  • play_data.py: CSV playback to OSC (for testing without a live vehicle)
  • rec_data.py: OBD-II data recording to CSV files
  • analyze_telemetry.py: timing, resolution and dropout analysis of the recordings
  • CSV_files/: test datasets (18 recorded driving scenarios)
  • Data Acquisition Protocol.pdf: OBD-II protocol documentation and signal specifications

Usage

Live Operation with Vehicle

For most vehicles, use live_data_universal.py to automatically detect both the serial port and the OBD protocol.

If you want to rely on auto-detection, leave PORT = None in live_data_universal.py:

PORT = None                 # Automatic serial port detection
BAUD = 115200               # Standard baud rate for vLinker

If the script can't connect automatically, it will print the detected serial ports and ask you to set PORT manually:

PORT = "COM8"              # Serial port override (Windows)
# or
PORT = "/dev/ttyUSB0"      # Serial port override (macOS/Linux)

Then activate the environment and start the real-time bridge:

source .venv/bin/activate
python live_data_universal.py

If you still need the original fixed-port version, live_data.py works the same way as before, with explicit PORT, BAUD, and PROTOCOL settings.

The system connects to your vehicle and streams telemetry to Max MSP at 127.0.0.1:5005 via OSC.

Testing with Recorded Data (No Vehicle Required)

Use pre-recorded test datasets to develop and refine the Max algorithm without a live vehicle connection.

Step 1: Configure data-tools/play_data.py with a CSV file path:

CSV_PATH = r"./CSV_files"           # Folder containing CSV files
FILENAME = "01_CAL_Pilot_Run_Check.csv"  # Choose any test dataset

Step 2: Run the playback:

cd data-tools
python play_data.py

The system replays recorded telemetry at its original timing, including the jitter and the dropped frames, which is the point.

Recording New Vehicle Data

To capture fresh telemetry for testing and validation:

Step 1: Configure data-tools/rec_data.py with your vehicle port and save location:

PORT = "COM8"              # Serial port (same as live_data.py)
SAVE_PATH = r"./CSV_files"  # Where to save recorded CSV
FILENAME = f"my_recording.csv"  # Unique filename

Step 2: Start recording:

cd data-tools
python rec_data.py

Press Ctrl+C to stop recording. The CSV file will be saved in SAVE_PATH with timestamp, RPM, engine load, and speed columns.

Note that rec_data.py sleeps a fixed 50 ms after its queries rather than subtracting query time from a target period, so recordings land at ~10.4 Hz while live_data.py targets 20 Hz. This is intentional and documented under Known Limits & Scope: keep it as it is, and new recordings stay directly comparable with the existing 18 datasets.

Analysing Recorded Telemetry

To reproduce every timing figure quoted in this README:

cd data-tools
python analyze_telemetry.py

Reports the effective frame rate and jitter per run, the cost of one OBD-II round-trip and the projected cost of additional PIDs, control-signal quantisation and per-frame movement, and the structure of dropped frames. Standard library only, so it runs without the virtual environment.

Data Format

All CSV files use the following structure:

timestamp,rpm,engine_load,speed
0.000,850,15.3,0
0.096,850,15.3,0
0.192,860,16.1,2
...
  • timestamp: elapsed time in seconds (starts at 0.0, ~96 ms steps)
  • rpm: engine RPM (0-4123 observed across the datasets)
  • engine_load: engine load percentage (0-100%, ~0.39% resolution)
  • speed: vehicle speed in km/h (integer resolution, 0-122 observed)

Test Datasets

The data-tools/CSV_files/ folder contains 18 pre-recorded driving scenarios, 35.8 minutes of telemetry in total:

  • Calibration (CAL): idle, steady cruising at 30/50/90 km/h, pilot run, power cycle
  • Dynamics (DYN): acceleration at 25/50/100% throttle, coast-down, direction change, parking manoeuvres
  • Stress (STR): uphill climb, downhill descent, traction load spikes
  • Environment (ENV): city-centre and suburban driving profiles, plus highway max speed

Between them they contain the two failure modes that shaped the engine: the multi-second speed freezes at steady cruise (03-05, 14) and the periodic dropped frames that only appear in sessions longer than ~4.5 minutes (16, 17).

Dependencies

  • python-obd>=0.7.1: OBD-II protocol communication with vehicle ECU
  • python-osc>=1.8.0: Open Sound Control (OSC) client for Max MSP integration
  • pyserial>=3.5: serial port communication for vLinker adapter
  • Max 8+: audio synthesis and real-time signal processing (not installed by pip)

analyze_telemetry.py requires none of these.

Troubleshooting

"Unable to connect to the car"

  • Verify vLinker is powered and connected to the USB port
  • Check that PORT matches your serial port (use Device Manager on Windows, ls /dev/tty* on macOS/Linux)
  • Make sure the vehicle is in "on" or "ready" state, not off

"error: File not found" (in play_data.py)

  • Verify CSV_PATH and FILENAME are correct and the file exists
  • Use absolute paths if relative paths aren't working

OSC messages not reaching Max MSP

  • Verify the Max patch is listening on port 5005
  • Confirm UDP_IP = "127.0.0.1" in the Python script (localhost)
  • Check that the firewall isn't blocking UDP on port 5005

The pitch drops to nothing for an instant

Expected on the current build, and it comes from two separate causes. On a vehicle with a gearbox it happens at every shift, and isn't treated as a defect: the target vehicle class is single-speed. Independently of that, sessions longer than ~4.5 minutes show a lone dropped frame roughly every 12.3 seconds, which comes from the acquisition stack rather than the car. Both are described in What happens when a frame is lost.

Development Workflow

  1. Prototype: iterate on the Max algorithm against recorded CSV data with play_data.py
  2. Analyse: check what the data actually does with analyze_telemetry.py before assuming a problem is in the patch
  3. Validate: test with a live vehicle across different driving scenarios
  4. Record: capture new cases with rec_data.py for future validation
  5. Refine: return to step 1

Notes

  • The project uses Protocol 7 (CAN 29-bit) hardcoded in live_data.py for vLinker compatibility; live_data_universal.py auto-detects instead
  • Effective sampling rate is 10.4 Hz (96 ms median interval) for the recorded datasets, and ~20 Hz for the live bridge. The two scripts compute their sleep differently, see Recording New Vehicle Data
  • OSC messages are sent to address /car with payload [rpm, load, speed]
  • All Python scripts except analyze_telemetry.py require the virtual environment activated: source .venv/bin/activate

License

This project is licensed under CC BY-NC-SA 4.0 (Attribution-NonCommercial-ShareAlike). You're free to use and modify it, including the code, patches and audio assets, as long as you credit Matteo Caruso Linardon, don't use it commercially, and release any derivative under the same license. See LICENSE for the full terms.

About

Real-time EV Active Sound Design (ASD) engine implementing hybrid wavetable/granular synthesis in Max MSP, driven by OBD-II telemetry via an OSC-Python bridge.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Contributors

Languages