<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Automation on Boris Dupoux</title><link>https://borisdupoux.com/categories/automation/</link><description>Recent content in Automation on Boris Dupoux</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Mon, 01 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://borisdupoux.com/categories/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>Deterministic Process Automation Under Strong Security Constraints</title><link>https://borisdupoux.com/research/deterministic-rpa/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/deterministic-rpa/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;A client operating under strong security constraints manages several hundred
sites and runs four recurring back-office processes every day: maintaining field
equipment, handling recurring spurious equipment events, writing intervention
reports for its customers, and checking that each site&amp;rsquo;s periodic health check
has arrived. The inputs are scattered across three systems with very different
access paths: PDF reports delivered by e-mail, a business web portal with no
public API, and Excel workbooks maintained by hand over many years.&lt;/p&gt;
&lt;p&gt;The goal: automate these processes on an office workstation while keeping a
human operator as the final authority on every customer message.&lt;/p&gt;
&lt;h2 id="design-constraints"&gt;&lt;a href="#design-constraints" class="header-anchor"&gt;&lt;/a&gt;Design constraints
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No language model at run time.&lt;/strong&gt; Every decision comes from explicit rules
and every sentence from a template. This makes the robot&amp;rsquo;s output
reproducible: the same inputs always produce the same drafts, so a new version
can be compared word for word against production, and any change traces back
to a rule or a data source. No customer data goes to an external model
service. A vision-language model was used once, offline, during design, to map
recorded operator screen sessions; it is not part of the deployed system.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drafts, not sends.&lt;/strong&gt; The robot only creates Outlook drafts; the operator
reviews and sends them. A global kill switch forces draft mode regardless of
per-process settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local execution, no new infrastructure.&lt;/strong&gt; Python on a Windows workstation,
scheduled tasks, a shared NAS folder for state and configuration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No plaintext secrets.&lt;/strong&gt; Credentials and tokens live in the Windows
Credential Manager (DPAPI); configuration is YAML the operator can edit
without a rebuild.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;&lt;a href="#architecture" class="header-anchor"&gt;&lt;/a&gt;Architecture
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 SCHED["Scheduled passes (morning / afternoon)"] --&gt; ING
 MAIL["Mailbox (Microsoft Graph, read)"] --&gt; ING["Ingestion PDF reports grouped by day"]
 ING --&gt; PARSE["PDF parser (pypdf + regular expressions)"]
 PORTAL["Business web portal Playwright login → session token → JSON endpoints replayed"] --&gt; RULES
 XLS["Reference &amp; history workbooks (Excel)"] --&gt; RULES
 PARSE --&gt; RULES["Rule engine 4 processes, ordered rules &amp; exclusions"]
 RULES --&gt; TPL["Template composer French agreement, names, dates"]
 TPL --&gt; DRAFTS["Outlook drafts (Graph, never sent)"]
 RULES --&gt; TRACK["Tracking workbooks (atomic append)"]
 DRAFTS --&gt; OP(["Operator reviews &amp; sends"])
 OP -. "sent items: corrected recipients" .-&gt; RULES
 NAS[("NAS: locks, heartbeats, journal, idempotence ledger")] --- RULES
 RULES --&gt; HEALTH["Health alerts webhook + dead-man check"]&lt;/pre&gt;&lt;h2 id="the-four-processes"&gt;&lt;a href="#the-four-processes" class="header-anchor"&gt;&lt;/a&gt;The four processes
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Equipment maintenance.&lt;/strong&gt; Fault lines parsed from the daily PDF reports are
matched against each device&amp;rsquo;s battery and repair history. An ordered decision
tree (first match wins) chooses between ignoring, waiting, battery replacement,
device replacement or radio-range testing, and drafts a technician work order;
each device on a multi-device order keeps its own decision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recurring spurious events.&lt;/strong&gt; Event counts per site and per device over 24
hours are compared with thresholds to detect recurring spurious events. Portal
data (an incident confirmed as real, the system being switched off minutes
after a burst) overrides the counts; the outcome is an advisory e-mail to the
customer, a work order, or nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intervention reports.&lt;/strong&gt; For each on-site intervention, the robot rebuilds a
single timeline from the portal&amp;rsquo;s event log, call log and field-staff records,
handles events that straddle midnight, and drafts a customer report with the
media files attached.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Periodic health checks.&lt;/strong&gt; Sites whose periodic health check is missing are
identified, the status of each connectivity channel is checked on the portal,
and a customer e-mail, a technician work order or an internal note is drafted.&lt;/p&gt;
&lt;h2 id="composing-an-e-mail-from-parameters-to-a-draft"&gt;&lt;a href="#composing-an-e-mail-from-parameters-to-a-draft" class="header-anchor"&gt;&lt;/a&gt;Composing an e-mail: from parameters to a draft
&lt;/h2&gt;&lt;p&gt;Each draft is the end of a long chain of explicit decisions. Counted in the
source code (October 2026), the four rule engines have &lt;strong&gt;53 terminal outcomes&lt;/strong&gt;
(each place where a rule returns a decision), mapped onto &lt;strong&gt;37 decision types&lt;/strong&gt;
and &lt;strong&gt;13 draft builders&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The customer advisory e-mail of the spurious-events process shows the scale. A
site reaches it only after up to &lt;strong&gt;14 ordered tests&lt;/strong&gt; in the rule engine (16
terminal outcomes for this process alone). The composer then reads &lt;strong&gt;21
inputs&lt;/strong&gt; — 17 fields of the site&amp;rsquo;s decision record and customer record, the
recipient list, the attachments, and two learned resources (zone names from the
portal and a spelling dictionary learned from the operator&amp;rsquo;s own history
workbook). Between the decision and the final text there are &lt;strong&gt;108 conditional
branch points&lt;/strong&gt; (&lt;code&gt;if&lt;/code&gt; statements, conditional expressions and boolean operands,
counted on the syntax tree of the composer and the ten helpers it calls, before
the HTML pass).&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 IN(["1 · Rule engine — 14 ordered tests, first match wins. Input: daily event reports · portal journal · customer record · learned site profile"]) --&gt; T0

 T0["Blocking flag on the site record?"] -- yes --&gt; X0["No e-mail"]
 T0 -- no --&gt; T1["R6 · incident confirmed as real today?"]
 T1 -- yes --&gt; X1["No e-mail"]
 T1 -- no --&gt; T2["R17 · media events &amp;lt; 30 s apart across zones?"]
 T2 -- yes --&gt; X2["Manual review"]
 T2 -- no --&gt; T3["R12 · external cause confirmed on media?"]
 T3 -- no --&gt; T4["R8 · ≥ 8 integrity events?"]
 T4 -- yes --&gt; T4b["persisting ≥ 3 days?"]
 T4b -- yes --&gt; X3["Technician work order"]
 T4b -- no --&gt; X4["Keep watching"]
 T4 -- no --&gt; T5["≥ 2 integrity events, ≥ half of the burst?"]
 T5 -- yes --&gt; X5["No e-mail"]
 T5 -- no --&gt; T6["R16 · ≥ 140 events, advisory ignored ≥ 4 weeks? (off by default)"]
 T6 -- yes --&gt; X6["Billing notice"]
 T6 -- no --&gt; T7["R15 · shared-building flag?"]
 T7 -- yes --&gt; X7["Coordination e-mail"]
 T7 -- no --&gt; T8["R5 · weekend + chronic-device flag?"]
 T8 -- yes --&gt; X8["Temporary masking request"]
 T8 -- no --&gt; T9["R1 · same device ≥ 7 events?"]
 T9 -- no --&gt; T10["R2 · ≥ 5 devices and ≥ 8 events?"]
 T10 -- no --&gt; T11["R4 · ≥ 8 events, ≥ 50 % 05:00–07:00?"]
 T11 -- no --&gt; T12["R3 · ≥ 8 events, ≥ 60 % 12:00–18:00, ≥ 70 % in 4 h?"]
 T12 -- no --&gt; T13["R1bis · site total ≥ 7 events?"]
 T13 -- no --&gt; X9["No e-mail"]

 T3 &amp; T9 &amp; T10 &amp; T11 &amp; T12 &amp; T13 -- yes --&gt; ADV

 ADV(["2 · Composer — advisory e-mail, rule id carried along. Customer name: legal form removed, re-cased, acronyms kept"])
 ADV --&gt; SUBJ["≥ 30 events?"]
 SUBJ -- yes --&gt; S1["Subject with urgent prefix"]
 SUBJ -- no --&gt; S2["Plain subject"]
 S1 &amp; S2 --&gt; GR["Contact cell"]
 GR -- "several people, or a role / organisation word" --&gt; G1["Generic greeting"]
 GR -- "title + surname" --&gt; G2["Greeting + normalised title (7 abbreviations) + SURNAME"]
 GR -- "surname only" --&gt; G3["Greeting + SURNAME"]
 G1 &amp; G2 &amp; G3 --&gt; DEV["Devices involved"]
 DEV -- one --&gt; D1["Portal zone name if known, else cleaned label + jargon map + learned accents"]
 DEV -- several --&gt; D2["Generic phrase + list of ≤ 6 by count, each with its device code if known"]
 D1 &amp; D2 --&gt; HND["System switched on/off within minutes of the burst? (R1–R4 only)"]
 HND -- "off just after" --&gt; C1["Handling-error wording A"]
 HND -- "on just before" --&gt; C2["Handling-error wording B"]
 HND -- no --&gt; ACT["Site in activity? — all of: R1–R4 or R1bis · ≥ 2 devices · ≥ 10 events · no intervention in the window · professional site · ≥ 90 % in learned hours · media sensors only: ≥ 90 % while on"]
 ACT -- yes --&gt; C3["Site-in-activity wording"]
 ACT -- no --&gt; RUL["Rule that fired"]
 RUL -- "R1, R2" --&gt; C4["Generic environmental cause"]
 RUL -- "R3, R4" --&gt; C5["Staff-passage cause"]
 RUL -- R12 --&gt; C6["External cause confirmed on media"]
 RUL -- R1bis --&gt; C7["Cause to investigate on site"]
 C4 &amp; C5 --&gt; ATT["Media actually attached?"]
 ATT -- yes --&gt; A1["+ reference to the attachments"]
 ATT -- no --&gt; FLG
 A1 &amp; C1 &amp; C2 &amp; C3 &amp; C6 &amp; C7 --&gt; FLG["Site flag: frequent contractor works?"]
 FLG -- yes --&gt; F1["Request: switch off before works"]
 FLG -- no --&gt; F2["Request: treat the cause"]
 F1 &amp; F2 --&gt; REC["Addresses in the customer record?"]
 REC -- yes --&gt; RC1["All addresses found"]
 REC -- no --&gt; RC2["Internal fallback mailbox"]
 RC1 &amp; RC2 --&gt; DRAFT(["Plain text → Outlook HTML (unfolding, lists, fixed typography, signature) → drafts folder, never sent"])
 classDef q stroke-dasharray:4 3,stroke-width:2px;
 class T0,T1,T2,T3,T4,T4b,T5,T6,T7,T8,T9,T10,T11,T12,T13,SUBJ,GR,DEV,HND,ACT,RUL,ATT,FLG,REC q;&lt;/pre&gt;&lt;p&gt;The wording space of this single e-mail: 2 subject forms, 3 greeting forms, 2
opening-sentence forms, 7 reachable cause wordings (two of them with or without
a reference to attached media), 2 requested actions, and an optional device list
of 2 to 6 lines. Every threshold above lives in the YAML configuration, not in
the code.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Parameter&lt;/th&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;Effect on the e-mail&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Event count over 24 h&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;figure in the opening sentence; ≥ 30 adds an urgent prefix to the subject&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Rule that fired&lt;/td&gt;
					&lt;td&gt;rule engine&lt;/td&gt;
					&lt;td&gt;selects the cause wording; gates the handling-error and site-in-activity checks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Devices involved (cleaned, de-duplicated)&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;one named device, or a generic phrase plus a list of up to 6 lines ordered by count&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Device code per report line&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;appended in parentheses to each listed device, only when known&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Zone name of the main device&lt;/td&gt;
					&lt;td&gt;portal&lt;/td&gt;
					&lt;td&gt;replaces the raw report label when available&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Learned spellings&lt;/td&gt;
					&lt;td&gt;operator&amp;rsquo;s history workbook&lt;/td&gt;
					&lt;td&gt;restores accents and casing of device names&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Title and contact-name cell&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;one of 3 greeting forms; several people, a role or an organisation name fall back to the generic form&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Company name&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;legal form removed, re-cased with acronyms kept, used in subject and body&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Site type, learned opening hours, interventions in the window&lt;/td&gt;
					&lt;td&gt;tracking workbook, portal journal&lt;/td&gt;
					&lt;td&gt;enable or suppress the site-in-activity wording&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;System switched on/off around the burst&lt;/td&gt;
					&lt;td&gt;portal journal&lt;/td&gt;
					&lt;td&gt;handling-error wording (2 variants), overriding every other cause&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Site flags&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;choice between the 2 requested actions&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Attachments present&lt;/td&gt;
					&lt;td&gt;portal media&lt;/td&gt;
					&lt;td&gt;reference to the attachments, or nothing&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Recipient addresses&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;every address found; otherwise an internal fallback mailbox&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The other drafts draw on the same shared French-language helpers: the
preposition before a site name agrees with its first noun (6 forms, from &lt;em&gt;chez&lt;/em&gt;
to &lt;em&gt;à l&amp;rsquo;&lt;/em&gt; and &lt;em&gt;aux&lt;/em&gt;); report titles agree in gender and number; dates are
written in full French form; a &amp;ldquo;Contact&amp;rdquo; heading becomes plural when several
contacts are listed; and an 8-entry map turns technical equipment jargon into
customer vocabulary.&lt;/p&gt;
&lt;h2 id="engineering-for-reliability"&gt;&lt;a href="#engineering-for-reliability" class="header-anchor"&gt;&lt;/a&gt;Engineering for reliability
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Idempotence.&lt;/strong&gt; A daily ledger keyed on site, message type and subject hash
prevents duplicate drafts; a cross-day register prevents re-issuing a work
order before the previous intervention has happened. Missed days are caught up
automatically.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency on a shared NAS.&lt;/strong&gt; Exclusive lock files (&lt;code&gt;O_CREAT|O_EXCL&lt;/code&gt;) with
a time-to-live for orphans; two workstations publish heartbeats, and the
secondary takes over only when the primary has been silent for two minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safe spreadsheet writes.&lt;/strong&gt; Writes go through a temporary file and an atomic
replace, with retries when the workbook is open by a user; inserted rows shift
footer formulas so totals stay correct.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network robustness.&lt;/strong&gt; Graph calls handle token refresh, HTTP 429
&lt;code&gt;Retry-After&lt;/code&gt; and 5xx back-off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicit failure.&lt;/strong&gt; A crash wrapper with no dependency on the main package
sends a redacted alert and a dead-man ping even when imports fail; a sentinel
raises an alert when the portal returns no data at all instead of reporting
success.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shadow benchmarking.&lt;/strong&gt; A sandbox build replays a day in read-only mode and
diffs its drafts against production. The acceptance metric is the share of
drafts the operator sends without changing a single word.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1,766&lt;/strong&gt; automated tests in the repository (October 2026), built on mock
mail and portal adapters, with live-system tests marked separately.&lt;/li&gt;
&lt;li&gt;An audit of the corpus of received reports showed that &lt;strong&gt;9.5 %&lt;/strong&gt; of event
lines were being silently discarded, because event codes and labels are
concatenated in the extracted PDF text; the parser was rewritten to recognise
the code forms actually emitted.&lt;/li&gt;
&lt;li&gt;Caching the fault-history workbook (1.49 MB, 15,533 rows) cut the equipment
maintenance process on a replay of real reports from &lt;strong&gt;161.1 s to 23.3 s&lt;/strong&gt;;
workbook opens fell from 26 to 3.&lt;/li&gt;
&lt;li&gt;First clean shadow comparison against production: &lt;strong&gt;14 drafts on each side,
14 in common, 0 missed, 0 added&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python 3.11&lt;/code&gt; · &lt;code&gt;Microsoft Graph&lt;/code&gt; · &lt;code&gt;MSAL&lt;/code&gt; · &lt;code&gt;httpx&lt;/code&gt; · &lt;code&gt;Playwright&lt;/code&gt; · &lt;code&gt;pypdf&lt;/code&gt; ·
&lt;code&gt;openpyxl&lt;/code&gt; · &lt;code&gt;pydantic&lt;/code&gt; · &lt;code&gt;keyring&lt;/code&gt; · &lt;code&gt;pytest&lt;/code&gt; · &lt;code&gt;PyInstaller&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Field Identification of Recall-Affected Vehicles</title><link>https://borisdupoux.com/research/fleet-management/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/fleet-management/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;Safety recall campaigns depend on reaching every affected vehicle, but
letters and phone calls leave a residual population of owners who never
respond. In island and overseas territories the problem is sharper: network
coverage is uneven, field logistics span several territories, and the
registry of who drives what is incomplete. The practical question becomes a
fleet-management one: &lt;em&gt;which vehicles of the circulating fleet are still
affected, where are they seen, and how far has each one progressed towards
repair?&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Recall listing&lt;br/&gt;(scheduled file pull or manual CSV)"] --&gt; B["Central server&lt;br/&gt;import · versioning · model-name decoding"]
 B --&gt;|"incremental diff"| C["Field units (laptops in agent vehicles)&lt;br/&gt;local recall copy"]
 D["Camera stream"] --&gt; E["Plate detection + OCR&lt;br/&gt;(on-device, offline)"]
 E --&gt; F["Format-aware correction&lt;br/&gt;+ double-read confirmation"]
 F --&gt; G{"Match against&lt;br/&gt;local recall list?"}
 G --&gt;|"no"| H["Discarded — nothing stored"]
 G --&gt;|"yes"| I["Alert to agent&lt;br/&gt;+ local queue"]
 I --&gt;|"sync when network returns"| B
 B --&gt; J["Dashboard&lt;br/&gt;units · tours · detections · incidents"]
 J --&gt; K["Owner contact workflow&lt;br/&gt;identified → contacted → repaired"]&lt;/pre&gt;&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Data ingestion and versioning.&lt;/strong&gt; The recall listing is pulled
automatically on a schedule or imported by hand. Each import is diffed
against the previous one with stable identifiers, versioned by hash, and
only the delta is sent to the field units. Manufacturer type codes are
translated into readable model names on import.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Matching under OCR noise.&lt;/strong&gt; Plate detection and character recognition run
on the field laptop with ONNX models, with no network dependency. Two
safeguards reduce false matches: (i) a &lt;em&gt;positional correction&lt;/em&gt; step uses the
known structure of national plate formats (current and legacy) to resolve
classic OCR confusions (O/0, I/1, S/5, B/8, Z/2, G/6), rejecting any
ambiguous reading; (ii) a &lt;em&gt;double-read confirmation&lt;/em&gt; only counts a plate when
it is read twice within a short time window. A smartphone web page offers a
manual fallback (photo or typed plate, plus VIN check-digit validation).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offline-first synchronisation.&lt;/strong&gt; Matches are queued locally and pushed
to the central server on the next connection, so detections survive
multi-day outages. Units report a heartbeat (software version, recall-list
version, reading counters).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Field operations.&lt;/strong&gt; Supervisors plan tours as ordered, geolocated
checkpoints on a map, assign them to teams and units, reuse templates, and
follow progress. Agents report incidents with a severity level. A contact
workflow records the outcome of each owner approach, and the dashboard
derives a conversion funnel (identified → approached → engaged → repaired)
with weekly trends, the median delay to repair and a breakdown by territory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Privacy by design.&lt;/strong&gt; Plates that do not match the recall list are never
stored or transmitted; retained records have fixed retention periods with
automatic purge; actions are written to an audit log; dashboard access is
role-based, including read-only accounts; data at rest is encrypted. A
processing register was written alongside the code.&lt;/p&gt;
&lt;h2 id="deployment--operations-devops"&gt;&lt;a href="#deployment--operations-devops" class="header-anchor"&gt;&lt;/a&gt;Deployment &amp;amp; operations (DevOps)
&lt;/h2&gt;&lt;p&gt;The system runs on two very different kinds of machine: Windows laptops in
agents&amp;rsquo; vehicles, which may stay offline for days, and one server per
instance. After the first weeks in the field, most of the engineering work
went into making both sides easy to deploy, observe and recover, by a single
person, without breaking field work.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 subgraph ADM["Admin workstation"]
 GIT["Git repository&lt;br/&gt;committed tree only"]
 PROV["Provisioning script&lt;br/&gt;one config file per unit"]
 BLD["Field build&lt;br/&gt;PyInstaller bundle + ONNX models"]
 end
 subgraph SRV["Server (Ubuntu LTS VPS)"]
 CAD["Caddy&lt;br/&gt;TLS · HSTS · no access log"]
 APP["Docker container&lt;br/&gt;FastAPI API + dashboard&lt;br/&gt;loopback port only"]
 VOL[("LUKS2 volume&lt;br/&gt;SQLite · photos · archives")]
 TIM["systemd timers&lt;br/&gt;monitoring · backup · cleanup"]
 end
 FLD["Field laptops&lt;br/&gt;local SQLite · sync loop"]
 OBJ[("Off-site object storage&lt;br/&gt;age-encrypted backups")]
 CHAT["Chat alerts&lt;br/&gt;state changes + daily digests"]
 GIT --&gt;|"git archive over SSH → deploy script"| APP
 PROV --&gt;|"admin API"| APP
 PROV --&gt;|"config file + README"| FLD
 BLD --&gt;|"archive + one-click installer"| FLD
 FLD &lt;--&gt;|"HTTPS: heartbeat · pushes · recall diff"| CAD
 CAD --&gt; APP
 APP --- VOL
 TIM --&gt; VOL
 TIM --&gt;|"encrypted copy"| OBJ
 TIM --&gt; CHAT&lt;/pre&gt;&lt;h3 id="field-laptops-packaging-installation-and-updates"&gt;&lt;a href="#field-laptops-packaging-installation-and-updates" class="header-anchor"&gt;&lt;/a&gt;Field laptops: packaging, installation and updates
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Packaging.&lt;/strong&gt; The field application (Python backend, React interface) is
frozen with PyInstaller into a one-folder Windows executable that embeds the
ONNX models and the OCR configuration, so a unit needs no Python install and
no network to read plates. Built-in &lt;code&gt;--diag&lt;/code&gt; and &lt;code&gt;--selftest&lt;/code&gt; modes check the
bundled runtime and read a test image without starting the interface, which
made start-up failures diagnosable on the field machine itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One-click installer.&lt;/strong&gt; The build is delivered as an archive with a
double-click &lt;code&gt;INSTALLER.bat&lt;/code&gt;. It elevates itself and runs a PowerShell script
that removes the &amp;ldquo;downloaded from the Internet&amp;rdquo; mark from the files (otherwise
the bundled runtime fails to load), disables USB power saving so the GPS
dongle is not suspended, and installs the unit&amp;rsquo;s configuration file where the
executable reads it. The script refuses to guess: if several &lt;em&gt;different&lt;/em&gt;
configuration files are present, it lists them and asks which one is the new
one. An optional reset clears the local database when a unit moves to another
server; it shows the target server before asking for confirmation and warns
that unsynchronised detections would be lost. The unit&amp;rsquo;s access token is
never displayed. An update follows the same path: new build, re-run the
installer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Provisioning.&lt;/strong&gt; A Python script that uses only the standard library, so it
runs on a bare Windows machine, creates the units from a CSV file through the
server&amp;rsquo;s admin API and generates, for each unit, its configuration file
(server address, unit code, display name, token) and a README to hand over
with the laptop. The admin key is read from an environment variable or a
masked prompt, never from a command-line argument.&lt;/p&gt;
&lt;h3 id="versioning-software-and-recall-list"&gt;&lt;a href="#versioning-software-and-recall-list" class="header-anchor"&gt;&lt;/a&gt;Versioning: software and recall list
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Software version.&lt;/strong&gt; Each unit reports its application version in its
heartbeat. The dashboard compares it with the expected release and flags the
units that still need updating.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recall-list version.&lt;/strong&gt; The recall list is versioned by hash. On each cycle,
a unit compares its local hash with the server&amp;rsquo;s: identical, nothing to do; no
local copy, full download; otherwise, the server returns only the rows added
and removed since that hash (and the unit falls back to a full download if the
server no longer knows its version). The change is applied in a single
transaction, and the local hash only moves forward if everything was applied.&lt;/p&gt;
&lt;p&gt;That last rule came from a field incident. A database constraint made the
diff fail on one unit, the error was swallowed by the sync loop, and the unit
kept sending heartbeats while its recall list stayed frozen for more than two
weeks, so it alerted on vehicles that had already been repaired. Release 1.4.1
fixed the cause, added an explicit rollback, and shipped a schema migration
that keeps existing data. It was tested with a 62-test run that included
reproducing the bug and migrating a database with the old schema. Monitoring
now raises a &amp;ldquo;recall sync stuck&amp;rdquo; alert when a unit has not received a
published list after a grace period.&lt;/p&gt;
&lt;h3 id="incremental-resilient-synchronisation"&gt;&lt;a href="#incremental-resilient-synchronisation" class="header-anchor"&gt;&lt;/a&gt;Incremental, resilient synchronisation
&lt;/h3&gt;&lt;p&gt;Every five minutes, or on demand, a unit runs a sync cycle made of
independent steps: heartbeat, push of detections (in batches), GPS track,
incidents, checkpoint updates, then pull of the recall list and the tours.
Pushes come before pulls, so field data leaves first. A failing step no
longer blocks the others: in an earlier version the cycle was monolithic,
and a failed heartbeat once delayed detections by 22 minutes. Requests use
short timeouts, downloads longer ones. The heartbeat carries the software
version, the recall-list version, the number of pending detections and the
reading counters, which is what the dashboard and the monitoring rely on.&lt;/p&gt;
&lt;h3 id="server-deployment"&gt;&lt;a href="#server-deployment" class="header-anchor"&gt;&lt;/a&gt;Server deployment
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Instance kit.&lt;/strong&gt; Each deployment is an independent instance described by a
short identifier, on an Ubuntu LTS virtual server. A bootstrap script, which is
interactive and can be safely re-run, installs the system packages, Docker
and Caddy from their official repositories. It enables unattended security
updates without automatic reboot, restricts SSH to key authentication,
enables a default-deny firewall and brute-force protection, and checks that
the DNS record points to the server before applying the TLS configuration.
Secrets live in root-only configuration files on the server, never in the
repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Container and reverse proxy.&lt;/strong&gt; A multi-stage Dockerfile builds the React
dashboard with Node, then runs the FastAPI server on a slim Python image as an
unprivileged user. Its health check allows a three-minute start period
because the plate and OCR models are loaded before the server listens. The
container is published on the loopback interface only. Caddy is the only
public entry point: automatic Let&amp;rsquo;s Encrypt certificate, explicit HSTS, and
no access log, because scan URLs contain plates. The application&amp;rsquo;s own
access log is disabled for the same reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deploy and rollback.&lt;/strong&gt; Code leaves the admin workstation only as a
&lt;code&gt;git archive&lt;/code&gt; of a committed tree: uncommitted changes are listed and not
sent, and line endings are forced to LF. On the server, the new tree is
unpacked next to the current one and swapped in, the previous one is kept,
and the deployed commit is recorded. The deploy script then detaches from the
SSH session, so a dropped connection interrupts neither the deployment nor a
rollback. A lock prevents concurrent deployments. The script builds the image
(labelled with its commit), keeps the previous image as &lt;code&gt;rollback&lt;/code&gt;, recreates
the container and waits until it reports healthy. It gives up after five
minutes, or as soon as the container has restarted twice. On failure it rolls
back automatically to the previous image and source tree, sets the failed
code aside and sends an alert. Going back to any earlier version means
re-deploying that commit. An image rollback does not undo a schema
migration, so the documented procedure is to take a backup before any
deployment that changes the schema.&lt;/p&gt;
&lt;h3 id="encrypted-volume-and-safe-restarts"&gt;&lt;a href="#encrypted-volume-and-safe-restarts" class="header-anchor"&gt;&lt;/a&gt;Encrypted volume and safe restarts
&lt;/h3&gt;&lt;p&gt;Server data (database, contact photos, recall archives) lives on a LUKS2
file container mounted with restrictive options. Its passphrase is never
stored on the machine, so after a reboot a person unlocks the volume. The
container restarts on its own, but the server refuses to start unless a
sentinel file &lt;em&gt;on the encrypted volume&lt;/em&gt; is present. Without it, a reboot
would have left an empty directory in place of the volume, and the
application would have created a fresh, unencrypted database there and
accepted field data into it. The guard was checked both ways on a throwaway
container. A separate script grows the volume while it is mounted.&lt;/p&gt;
&lt;h3 id="backups-and-recovery"&gt;&lt;a href="#backups-and-recovery" class="header-anchor"&gt;&lt;/a&gt;Backups and recovery
&lt;/h3&gt;&lt;p&gt;A daily systemd timer takes a consistent SQLite snapshot through the
database&amp;rsquo;s backup API, archives it with the photos and recall archives, and
encrypts the result with &lt;code&gt;age&lt;/code&gt; to a public key. The private key is shown
once, at creation, and kept off the server. Backups are kept locally and,
optionally, copied to S3-compatible object storage with &lt;code&gt;rclone&lt;/code&gt;, configured
through environment variables only. A restore &lt;em&gt;test&lt;/em&gt; mode decrypts a backup
into a temporary directory on the encrypted volume, runs an integrity check,
counts rows per table, then deletes everything. The &lt;em&gt;apply&lt;/em&gt; mode moves the
current state aside instead of deleting it. The documentation includes a
step-by-step rebuild after total loss of the server. The continuity plan
targets a recovery time of 4 hours and a maximum data loss of 24 hours.&lt;/p&gt;
&lt;h3 id="monitoring"&gt;&lt;a href="#monitoring" class="header-anchor"&gt;&lt;/a&gt;Monitoring
&lt;/h3&gt;&lt;p&gt;A watchdog runs every 15 minutes and only posts to a chat webhook when a
state changes (problem, then &amp;ldquo;resolved&amp;rdquo;). A fuller digest is sent twice a day,
timed to the field shifts. It checks that the encrypted volume is mounted, the
API health endpoint (with retries, because the daily import can block it for
about a minute), the container health status, that the database is readable,
disk usage on both the system disk and the encrypted volume, the size of the
SQLite write-ahead log, TLS certificate expiry, backup age and size, units
silent for more than 48 hours (not 24, since units are switched off at
weekends) and units whose recall list is stuck. A separate watcher alerts
when no recall import has happened for more than 30 hours. Several of these
checks were added after real incidents: the encrypted volume once reached
90 % without any alert because only the system disk was watched. A weekly
timer cleans up Docker build caches and keeps only the current and rollback
images. A dedicated script truncates the SQLite log with a short lock wait,
so field writes are never blocked for long.&lt;/p&gt;
&lt;h3 id="offline-resilience"&gt;&lt;a href="#offline-resilience" class="header-anchor"&gt;&lt;/a&gt;Offline resilience
&lt;/h3&gt;&lt;p&gt;The field side is designed to keep working through a server outage: plate
reading, matching against the local recall copy, alerts, tours already
downloaded and the local statistics tab all work without a network, and the
queue empties at the next connection. Only the dashboard (and the
server-hosted smartphone page) is unavailable while the server is down.&lt;/p&gt;
&lt;h3 id="testing"&gt;&lt;a href="#testing" class="header-anchor"&gt;&lt;/a&gt;Testing
&lt;/h3&gt;&lt;p&gt;There is no CI pipeline in the repository. Releases are tested before they
ship, and the commits record those runs: 12/12 and 6/6 for the plate
correction and confirmation stages, 62 tests for release 1.4.1. Behaviours
that cannot be unit-tested, such as the volume guard or a live deployment,
were checked on throwaway containers and against the production health
endpoint.&lt;/p&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;In early field operation, agents flagged &lt;strong&gt;32 %&lt;/strong&gt; of detections as
misreadings over roughly one week. This measurement motivated the
positional-correction and double-read confirmation stages, and the
dashboard statistics now exclude detections marked as false positives.&lt;/li&gt;
&lt;li&gt;The new stages shipped with unit tests: &lt;strong&gt;12/12&lt;/strong&gt; for plate correction
and &lt;strong&gt;6/6&lt;/strong&gt; for confirmation, settings and reading log.&lt;/li&gt;
&lt;li&gt;After the model-name mapping was extended, only &lt;strong&gt;one&lt;/strong&gt; row of the
production recall listing remained without a decoded model name.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No end-to-end recognition rate has been measured yet.&lt;/p&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;Recognition accuracy has not been measured against a ground-truth
benchmark. The specification targets (read rate, latency) are design goals,
not results.&lt;/li&gt;
&lt;li&gt;No legacy-format plate reading was observed at first. A dedicated counter
now tracks these readings end to end, but the fix still needs a field check.&lt;/li&gt;
&lt;li&gt;A false match against a large listing still costs agent time. Calibrating
the confidence threshold and confirmation window on labelled field video
is the next step.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;FastAPI&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;ONNX Runtime&lt;/code&gt; · &lt;code&gt;fast-alpr&lt;/code&gt; (YOLOv9 plate detector + OCR) · &lt;code&gt;OpenCV&lt;/code&gt; · &lt;code&gt;PaddleOCR&lt;/code&gt; · &lt;code&gt;React&lt;/code&gt; · &lt;code&gt;TypeScript&lt;/code&gt; · &lt;code&gt;Vite&lt;/code&gt; · &lt;code&gt;Leaflet&lt;/code&gt; · &lt;code&gt;Docker&lt;/code&gt; · &lt;code&gt;Caddy&lt;/code&gt; · &lt;code&gt;LUKS&lt;/code&gt; · &lt;code&gt;systemd&lt;/code&gt; · &lt;code&gt;age&lt;/code&gt; · &lt;code&gt;rclone&lt;/code&gt; · &lt;code&gt;PyInstaller&lt;/code&gt; · &lt;code&gt;PowerShell&lt;/code&gt;&lt;/p&gt;</description></item><item><title>From Multitrack Recordings to an Editable Timeline: Transcription, Diarisation and AAF Export</title><link>https://borisdupoux.com/research/speech-to-edit/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/speech-to-edit/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;A conference recorded on several microphones (speakers, room, music) produces
hours of raw multitrack audio. Most editing time goes into locating material —
who speaks when, on which track, where the silences are — before any cutting.&lt;/p&gt;
&lt;p&gt;Open-source speech tooling (Whisper and its derivatives, pyannote, Silero VAD)
solves transcription and &amp;ldquo;who spoke when&amp;rdquo; well, but stops at &lt;strong&gt;text&lt;/strong&gt;. The
question addressed here is whether that analysis can be delivered as an
&lt;strong&gt;editing object&lt;/strong&gt;: a multitrack timeline in AAF, the interchange format that
audio workstations open natively, with tracks qualified and dead time removed.&lt;/p&gt;
&lt;h2 id="before--after"&gt;&lt;a href="#before--after" class="header-anchor"&gt;&lt;/a&gt;Before → after
&lt;/h2&gt;&lt;p&gt;One real job processed end to end: a conference recorded on &lt;strong&gt;16 tracks&lt;/strong&gt;.
Everything on the right-hand side was produced automatically, with no manual
cut; figures are read from the job&amp;rsquo;s analysis database and from the delivered
AAF.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;Input (raw recording)&lt;/th&gt;
					&lt;th&gt;Generated output (AAF timeline)&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Tracks&lt;/td&gt;
					&lt;td&gt;16 × 1 h 32 min 54 s (≈ 24 h 46 min of audio to review)&lt;/td&gt;
					&lt;td&gt;16 tracks kept in the AAF, 6 carrying clips&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Track roles&lt;/td&gt;
					&lt;td&gt;4 speech · 2 music (one auto-flagged as ambience) · 10 silent&lt;/td&gt;
					&lt;td&gt;10 silent tracks left empty, no manual triage&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Material&lt;/td&gt;
					&lt;td&gt;204 speech regions and 5 music passages detected&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;41 clips&lt;/strong&gt; (37 speech, 4 music)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Cuts&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;40 hard cuts&lt;/strong&gt; between sources, of which 33 microphone switches&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Transitions&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;0 crossfades: straight cuts, each speech clip extended by a 0.3 s tail&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Dead time&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;3 gaps ≥ 4.5 s rippled out (36.9 s); timeline 1 h 32 min 17 s&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img alt="Multitrack before/after: 16 input tracks coloured by role over time, and the generated edit showing which track is kept per segment, with source switches marked" class="gallery-image" data-flex-basis="333px" data-flex-grow="138" height="1152" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/speech-to-edit/multitrack-before-after.png" srcset="https://borisdupoux.com/research/speech-to-edit/multitrack-before-after_hu_1f55e6879ef891c6.png 800w, https://borisdupoux.com/research/speech-to-edit/multitrack-before-after.png 1600w" width="1600"&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Top: the 16 input tracks over the full recording, coloured by the detected
role and activity; the four speech microphones are active almost all the time
because each one also picks up its neighbours. Bottom: the generated edit, one
lane per track as in the AAF — a single source is kept at any moment, ticks mark
the 40 source switches, hatched areas in the top panel are the removed dead
time.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Speech clips are few and long (median 79 s, longest 10 min 11 s): the montage
follows the active speaker rather than chopping on every breath, and
micro-switches shorter than 0.8 s are absorbed by the anti-flicker smoothing.
Ten clips are still shorter than 6 s — short interjections, the first place to
look when checking the edit.&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Multitrack WAV recording"] --&gt; B["Decode to 16 kHz mono + RMS energy per 100 ms"]
 B --&gt; C["Voice activity detection (Silero)"]
 C --&gt; D["Transcription (Whisper large-v3, chunked)"]
 D --&gt; E["Forced word alignment (WhisperX / wav2vec2)"]
 C --&gt; F["Speaker diarisation (pyannote)"]
 E --&gt; G["Speaker assignment + segment post-processing"]
 F --&gt; G
 B --&gt; H["Track role: speech / music / silent"]
 G --&gt; H
 H --&gt; I["Montage: active microphone per 100 ms frame"]
 I --&gt; J["AAF export (linked to original WAV names)"]
 I --&gt; K["Mono mixdown WAV"]&lt;/pre&gt;&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Transcription.&lt;/strong&gt; Whisper &lt;code&gt;large-v3&lt;/code&gt; via faster-whisper (CTranslate2), run on
fixed-length chunks inside an isolated, persistent worker process: the model is
loaded once, and a native crash only loses one chunk, which is reported rather
than silently dropped. Decoding uses beam search with Silero VAD filtering and
&lt;code&gt;condition_on_previous_text&lt;/code&gt; disabled to limit repetition loops.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Word timing and speakers.&lt;/strong&gt; Word-level timestamps come from WhisperX forced
alignment (wav2vec2). Speaker turns come from pyannote diarisation and are
assigned to words by temporal overlap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Post-processing.&lt;/strong&gt; Pure filters remove known Whisper hallucinations
(stock subtitle credits, &amp;ldquo;thanks for watching&amp;rdquo;-type phrases, music-only
segments), non-Latin output on French audio and duplicates, then merge
consecutive same-speaker segments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Track qualification and montage.&lt;/strong&gt; Each track is classified as speech, music
or silent by combining frame energy, VAD coverage and transcript statistics.
The montage selects, for every 100 ms frame, the microphone with the highest
signal-to-noise ratio above its own noise floor (reducing cross-talk
&amp;ldquo;bleed&amp;rdquo;), applies anti-flicker smoothing and ripples out global dead time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Synchronisation.&lt;/strong&gt; Separate recorders are aligned by matching distinctive
words across transcripts (robust median of offsets), after a coarse
clock-based placement corrected for clock drift.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Export.&lt;/strong&gt; The edit list is written as a multitrack AAF with &lt;code&gt;pyaaf2&lt;/code&gt;, either
embedding the audio or linking to it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deployment.&lt;/strong&gt; A web portal accepts jobs and a separate compute worker polls
it over HTTPS. Clients upload only compressed analysis copies together with a
manifest of their original files; the delivered AAF references the
&lt;strong&gt;original WAV names&lt;/strong&gt;, so the editor relinks full-quality sources locally and
the originals never leave the client&amp;rsquo;s machine.&lt;/p&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;p&gt;Benchmark (&lt;code&gt;bench_stt.py&lt;/code&gt;) on real conference audio: one-minute speech windows
selected by VAD on four speech tracks, Whisper &lt;code&gt;large-v3&lt;/code&gt; throughout, Apple
Silicon laptop, warm timings excluding model loading. No ground-truth
transcript was available, so quality figures are &lt;strong&gt;divergence from a
pseudo-reference&lt;/strong&gt; (WhisperX output), not absolute error.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Pipeline (CPU)&lt;/th&gt;
					&lt;th style="text-align: right"&gt;Real-time factor&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;openai-whisper&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.43&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WhisperX&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.51&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WhisperX + forced alignment&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.54&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;openai-whisper + word timestamps&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.57&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;faster-whisper (beam 5 + VAD)&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.66&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;faster-whisper + word timestamps&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.70&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Word timing:&lt;/strong&gt; native word timestamps deviate from forced alignment by a
median of ~205 ms (faster-whisper) and ~210 ms (openai-whisper); forced
alignment adds about 1.2 s per minute of audio.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transcript divergence:&lt;/strong&gt; 7–11 % word-level divergence between pipelines,
mostly punctuation, segmentation and normalisation on manual inspection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repetitions:&lt;/strong&gt; 1–2 % repeated content; no hallucinated passages observed on
these clips.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track detection:&lt;/strong&gt; the three music/silence tracks of the seven-track
recording were identified by VAD and skipped automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;Quality is measured against a pseudo-reference; a manually transcribed test
set is needed to report true word error rate and diarisation error rate.&lt;/li&gt;
&lt;li&gt;Benchmark clips are short (one minute per track) and all single-speaker
windows, so diarisation accuracy on multi-speaker overlap is not yet
quantified.&lt;/li&gt;
&lt;li&gt;CTranslate2 has no Metal backend: on Apple Silicon, faster-whisper and
WhisperX run on CPU, while CUDA is used where available. Device choice per
stage was benchmarked rather than assumed; a CUDA comparison remains to be
measured.&lt;/li&gt;
&lt;li&gt;AAF export targets Logic Pro import; other editors have not been
systematically tested.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;faster-whisper&lt;/code&gt; · &lt;code&gt;CTranslate2&lt;/code&gt; · &lt;code&gt;WhisperX&lt;/code&gt; · &lt;code&gt;pyannote.audio&lt;/code&gt; · &lt;code&gt;Silero VAD&lt;/code&gt; · &lt;code&gt;PyTorch (MPS / CUDA)&lt;/code&gt; · &lt;code&gt;ffmpeg&lt;/code&gt; · &lt;code&gt;pyaaf2&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;PHP&lt;/code&gt; · &lt;code&gt;MySQL&lt;/code&gt;&lt;/p&gt;</description></item></channel></rss>