app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
"""The voice and the captions. Each narration line is one take of `adom-tts say`, so a caption starts exactly where its
line starts. When the voice service does not answer, the build goes on with captions only and says so.
"""
import concurrent.futures as cf
import hashlib
import os
import shutil
import subprocess
import wave
import numpy as np
from engine import ffmpeg_exe
VOICE = "en-US-AndrewNeural"
RATE = 48000
class VoiceDown(Exception):
pass
def _pcm(path):
out = subprocess.run([ffmpeg_exe(), "-v", "error", "-i", path, "-f", "s16le", "-ac", "1", "-ar", str(RATE), "-"], capture_output=True)
if out.returncode != 0:
raise VoiceDown(f"ffmpeg could not read {os.path.basename(path)}: {out.stderr.decode(errors='replace').strip()[-200:]}")
return np.frombuffer(out.stdout, np.int16)
def _trim(a):
"""The take without the silence the service puts before and after the words, keeping 40 ms each side."""
loud = np.flatnonzero(np.abs(a.astype(np.int32)) > 260)
if loud.size == 0:
return a[:0]
pad = int(0.04 * RATE)
return a[max(0, loud[0] - pad):min(a.size, loud[-1] + pad)]
def _say(text, path):
exe = os.environ.get("ADOM_AIFLOW_TTS") or "adom-tts"
env = dict(os.environ, ADOM_TTS_NO_HINTS="1")
last = ""
for _ in range(2):
try:
r = subprocess.run([exe, "say", "--out", path, "--voice", VOICE, "-"], input=text.encode(), capture_output=True, timeout=90, env=env)
except FileNotFoundError:
raise VoiceDown("adom-tts is not installed on this machine")
except subprocess.TimeoutExpired:
last = "adom-tts did not answer within 90 seconds"
continue
if r.returncode == 0 and os.path.isfile(path) and os.path.getsize(path) > 800:
return
last = (r.stderr.decode(errors="replace").strip() or r.stdout.decode(errors="replace").strip() or f"exit {r.returncode}")[-300:]
raise VoiceDown(f"adom-tts say failed: {last}")
def speak(lines, work, enabled=True):
"""Voice every line. Returns (takes, why): takes[i] is the trimmed PCM of line i, or (None, reason) when there is no voice."""
if not enabled:
return None, "the voice was turned off for this build (--no-voice)"
if not shutil.which(os.environ.get("ADOM_AIFLOW_TTS") or "adom-tts"):
return None, "adom-tts is not installed on this machine"
os.makedirs(work, exist_ok=True)
def one(text):
path = os.path.join(work, hashlib.sha1((VOICE + "|" + text).encode()).hexdigest()[:16] + ".mp3")
if not (os.path.isfile(path) and os.path.getsize(path) > 800):
_say(text, path)
a = _trim(_pcm(path))
if a.size < RATE * 0.25:
os.remove(path)
raise VoiceDown(f"the take for \"{text[:50]}\" came back empty")
return a
try:
with cf.ThreadPoolExecutor(4) as ex:
return list(ex.map(one, lines)), None
except VoiceDown as e:
return None, str(e)
def estimate(text):
"""How long a line takes to read when there is no voice to measure: about 2.6 words a second."""
return max(1.8, len(text.split()) / 2.6 + 0.35)
def write_wav(path, total, clips):
"""One mono track of `total` seconds with each (start seconds, pcm) clip laid in, levelled to leave 3 dB of headroom."""
mix = np.zeros(int(total * RATE) + RATE // 10, np.float32)
for start, a in clips:
b = a.astype(np.float32)
n = min(b.size, int(0.006 * RATE))
if n > 1:
ramp = np.linspace(0, 1, n, dtype=np.float32)
b[:n] *= ramp
b[-n:] *= ramp[::-1]
i = int(start * RATE)
b = b[:max(0, mix.size - i)]
mix[i:i + b.size] += b
peak = float(np.abs(mix).max()) if mix.size else 0.0
if peak > 0:
mix *= (32767 * 0.708) / peak
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(mix.astype(np.int16).tobytes())
def _stamp(t, sep):
t = max(0.0, t)
h, m, s = int(t // 3600), int(t % 3600 // 60), t % 60
return f"{h:02d}:{m:02d}:{s:06.3f}".replace(".", sep)
def caption_cues(lines, max_chars=84):
"""Split each spoken line into caption cues short enough to read (two lines of 42), of about equal length, timed by
their share of the letters."""
cues = []
for start, end, text in lines:
words = text.split()
n = max(1, -(-len(text) // max_chars))
parts = []
while n > 1 and len(words) > 1:
# the break that leaves this cue nearest its even share of what is left
want = len(" ".join(words)) / n
k = min(range(1, len(words)), key=lambda k: abs(len(" ".join(words[:k])) - want))
parts.append(" ".join(words[:k]))
words = words[k:]
n -= 1
parts.append(" ".join(words))
total = sum(len(p) for p in parts) or 1
t = start
for p in parts:
d = (end - start) * len(p) / total
cues.append((t, t + d, p))
t += d
return cues
def _two_lines(text):
if len(text) <= 42:
return text
words = text.split()
best = min(range(1, len(words)), key=lambda k: abs(len(" ".join(words[:k])) - len(" ".join(words[k:]))))
return " ".join(words[:best]) + "\n" + " ".join(words[best:])
def write_captions(base, cues):
with open(base + ".vtt", "w", encoding="utf-8") as f:
f.write("WEBVTT\n\n")
for k, (a, b, t) in enumerate(cues, 1):
f.write(f"{k}\n{_stamp(a, '.')} --> {_stamp(b, '.')}\n{_two_lines(t)}\n\n")
with open(base + ".srt", "w", encoding="utf-8") as f:
for k, (a, b, t) in enumerate(cues, 1):
f.write(f"{k}\n{_stamp(a, ',')} --> {_stamp(b, ',')}\n{_two_lines(t)}\n\n")