399 lines
16 KiB
Python
399 lines
16 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Generate PDF 1 (V2): Talas Microphone — Hardware Evidence
|
||
|
|
For NGI Zero Commons Fund application (25K EUR, personal tone).
|
||
|
|
|
||
|
|
PHOTOS: Place your own photographs in the photos/ directory:
|
||
|
|
photos/prototype_assembled.jpg — The assembled microphone (full view)
|
||
|
|
photos/mic_body_detached.jpg — Microphone body disassembled (modularity)
|
||
|
|
photos/capsule_sc_600.jpg — SC-600 capsule close-up
|
||
|
|
photos/pcb_preamp.jpg — Your preamp PCB (close-up)
|
||
|
|
photos/pcb_hex.jpg — Your hex inverter PCB (close-up)
|
||
|
|
photos/lab_setup.jpg — Your workbench with oscilloscope, tools
|
||
|
|
|
||
|
|
If a photo is missing, the script prints a warning and skips it.
|
||
|
|
KiCAD screenshots are used as-is (they're your own exports).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from fpdf import FPDF
|
||
|
|
from PIL import Image
|
||
|
|
import os
|
||
|
|
|
||
|
|
BASE = "/home/senke/Documents/TG__Talas_Group"
|
||
|
|
PHOTOS = f"{BASE}/09_MODELE_ECONOMIQUE/Subventions/photos"
|
||
|
|
CONCEPTION = f"{BASE}/02_PRODUITS_PHYSIQUES/Microphone/Conception"
|
||
|
|
OUT = f"{BASE}/09_MODELE_ECONOMIQUE/Subventions/talas-microphone-hardware-v2.pdf"
|
||
|
|
|
||
|
|
FONT_DIR = "/usr/share/fonts/liberation-sans-fonts"
|
||
|
|
|
||
|
|
|
||
|
|
class TalasPDF(FPDF):
|
||
|
|
def __init__(self):
|
||
|
|
super().__init__()
|
||
|
|
self.add_font("Sans", "", f"{FONT_DIR}/LiberationSans-Regular.ttf")
|
||
|
|
self.add_font("Sans", "B", f"{FONT_DIR}/LiberationSans-Bold.ttf")
|
||
|
|
self.add_font("Sans", "I", f"{FONT_DIR}/LiberationSans-Italic.ttf")
|
||
|
|
self.add_font("Sans", "BI", f"{FONT_DIR}/LiberationSans-BoldItalic.ttf")
|
||
|
|
|
||
|
|
def header(self):
|
||
|
|
self.set_font("Sans", "B", 9)
|
||
|
|
self.set_text_color(100, 100, 100)
|
||
|
|
self.cell(0, 5, "Talas — NGI Zero Commons Fund — Attachment 1: Hardware", align="R")
|
||
|
|
self.ln(8)
|
||
|
|
|
||
|
|
def footer(self):
|
||
|
|
self.set_y(-15)
|
||
|
|
self.set_font("Sans", "I", 8)
|
||
|
|
self.set_text_color(150, 150, 150)
|
||
|
|
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C")
|
||
|
|
|
||
|
|
def section_title(self, title):
|
||
|
|
self.set_font("Sans", "B", 13)
|
||
|
|
self.set_text_color(30, 30, 30)
|
||
|
|
self.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT")
|
||
|
|
self.set_draw_color(60, 60, 60)
|
||
|
|
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
|
||
|
|
self.ln(4)
|
||
|
|
|
||
|
|
def body_text(self, text):
|
||
|
|
self.set_font("Sans", "", 10)
|
||
|
|
self.set_text_color(40, 40, 40)
|
||
|
|
self.multi_cell(0, 5, text)
|
||
|
|
self.ln(2)
|
||
|
|
|
||
|
|
def personal_text(self, text):
|
||
|
|
"""Italic personal note — first person."""
|
||
|
|
self.set_font("Sans", "I", 10)
|
||
|
|
self.set_text_color(60, 60, 60)
|
||
|
|
self.multi_cell(0, 5, text)
|
||
|
|
self.ln(2)
|
||
|
|
|
||
|
|
def label_value(self, label, value):
|
||
|
|
self.set_font("Sans", "B", 10)
|
||
|
|
self.set_text_color(40, 40, 40)
|
||
|
|
self.cell(55, 6, label)
|
||
|
|
self.set_font("Sans", "", 10)
|
||
|
|
self.cell(0, 6, value, new_x="LMARGIN", new_y="NEXT")
|
||
|
|
|
||
|
|
def try_image(self, path, max_w=170, max_h=100, caption=None):
|
||
|
|
"""Add image if it exists, skip with warning if not."""
|
||
|
|
if not os.path.exists(path):
|
||
|
|
name = os.path.basename(path)
|
||
|
|
print(f" [SKIP] Photo missing: {name} — add it to photos/ and regenerate")
|
||
|
|
self.set_font("Sans", "I", 9)
|
||
|
|
self.set_text_color(180, 80, 80)
|
||
|
|
self.cell(0, 6, f"[Photo placeholder: {name}]",
|
||
|
|
new_x="LMARGIN", new_y="NEXT")
|
||
|
|
self.set_text_color(40, 40, 40)
|
||
|
|
self.ln(2)
|
||
|
|
return
|
||
|
|
|
||
|
|
img = Image.open(path)
|
||
|
|
w_px, h_px = img.size
|
||
|
|
ratio = min(max_w / w_px, max_h / h_px)
|
||
|
|
w_mm = w_px * ratio
|
||
|
|
h_mm = h_px * ratio
|
||
|
|
x = (self.w - w_mm) / 2
|
||
|
|
|
||
|
|
if self.get_y() + h_mm + 15 > self.h - 20:
|
||
|
|
self.add_page()
|
||
|
|
|
||
|
|
self.image(path, x=x, w=w_mm)
|
||
|
|
if caption:
|
||
|
|
self.set_font("Sans", "I", 8)
|
||
|
|
self.set_text_color(100, 100, 100)
|
||
|
|
self.cell(0, 5, caption, align="C", new_x="LMARGIN", new_y="NEXT")
|
||
|
|
self.ln(3)
|
||
|
|
|
||
|
|
def try_image_pair(self, path_l, path_r, w=60, max_h=120, cap_l="", cap_r=""):
|
||
|
|
"""Two images side by side, height-constrained."""
|
||
|
|
exists_l = os.path.exists(path_l)
|
||
|
|
exists_r = os.path.exists(path_r)
|
||
|
|
if not exists_l and not exists_r:
|
||
|
|
self.try_image(path_l) # will show placeholder
|
||
|
|
return
|
||
|
|
|
||
|
|
y_start = self.get_y()
|
||
|
|
h_max = 0
|
||
|
|
|
||
|
|
def fit(path, target_w, target_max_h):
|
||
|
|
img = Image.open(path)
|
||
|
|
w_px, h_px = img.size
|
||
|
|
ratio = min(target_w / w_px, target_max_h / h_px)
|
||
|
|
return target_w if (target_w / w_px) <= (target_max_h / h_px) else w_px * ratio, h_px * ratio
|
||
|
|
|
||
|
|
if exists_l:
|
||
|
|
fw, fh = fit(path_l, w, max_h)
|
||
|
|
h_max = max(h_max, fh)
|
||
|
|
if y_start + fh + 20 > self.h - 20:
|
||
|
|
self.add_page()
|
||
|
|
y_start = self.get_y()
|
||
|
|
self.image(path_l, x=12, y=y_start, w=fw, h=fh)
|
||
|
|
|
||
|
|
if exists_r:
|
||
|
|
fw, fh = fit(path_r, w, max_h)
|
||
|
|
h_max = max(h_max, fh)
|
||
|
|
self.image(path_r, x=105, y=y_start, w=fw, h=fh)
|
||
|
|
|
||
|
|
self.set_y(y_start + h_max + 2)
|
||
|
|
self.set_font("Sans", "I", 8)
|
||
|
|
self.set_text_color(100, 100, 100)
|
||
|
|
if cap_l or cap_r:
|
||
|
|
self.cell(95, 5, cap_l, align="C")
|
||
|
|
self.cell(95, 5, cap_r, align="C", new_x="LMARGIN", new_y="NEXT")
|
||
|
|
self.ln(3)
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
pdf = TalasPDF()
|
||
|
|
pdf.alias_nb_pages()
|
||
|
|
pdf.set_auto_page_break(auto=True, margin=20)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# PAGE 1 — Title + Personal intro + Prototype photo
|
||
|
|
# =========================================================
|
||
|
|
pdf.add_page()
|
||
|
|
|
||
|
|
pdf.set_font("Sans", "B", 20)
|
||
|
|
pdf.set_text_color(20, 20, 20)
|
||
|
|
pdf.cell(0, 12, "Talas One — Open Hardware Condenser Microphone",
|
||
|
|
align="C", new_x="LMARGIN", new_y="NEXT")
|
||
|
|
pdf.set_font("Sans", "", 11)
|
||
|
|
pdf.set_text_color(80, 80, 80)
|
||
|
|
pdf.cell(0, 7, "Hardware Evidence — March 2026 — Nikola Milovanovic",
|
||
|
|
align="C", new_x="LMARGIN", new_y="NEXT")
|
||
|
|
pdf.ln(6)
|
||
|
|
|
||
|
|
# Personal intro
|
||
|
|
pdf.section_title("1. What I Built")
|
||
|
|
pdf.personal_text(
|
||
|
|
"I designed and assembled this microphone from scratch. The circuit topology "
|
||
|
|
"is inspired by the Alice OPA design by DJJules (Instructables / JLI Electronics) "
|
||
|
|
"— a proven hex inverter + OPA preamp approach from the DIY audio community. "
|
||
|
|
"I modularized it into two independent PCBs in KiCAD and had them fabricated."
|
||
|
|
)
|
||
|
|
pdf.body_text(
|
||
|
|
"The prototype does not produce audio output yet — that is where this grant "
|
||
|
|
"starts. Debugging the circuit is Milestone 1."
|
||
|
|
)
|
||
|
|
|
||
|
|
# Prototype photo
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/prototype_assembled.jpg",
|
||
|
|
max_w=150, max_h=100,
|
||
|
|
caption="Fig. 1 — Assembled Talas One prototype (my photograph)"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Detached body — shows modularity / repairability
|
||
|
|
pdf.personal_text(
|
||
|
|
"The body is designed for disassembly. Each internal module (capsule, "
|
||
|
|
"preamp board, hex board) can be removed, inspected, or replaced individually — "
|
||
|
|
"this is core to the repairable, modular philosophy."
|
||
|
|
)
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/mic_body_detached.jpg",
|
||
|
|
max_w=140, max_h=90,
|
||
|
|
caption="Fig. 2 — Disassembled view of the microphone body"
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# Specs
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("2. Technical Specifications")
|
||
|
|
specs = [
|
||
|
|
("Type:", "Large-diaphragm condenser"),
|
||
|
|
("Capsule:", "t.bone SC-600 (34mm large-diaphragm, Thomann)"),
|
||
|
|
("Preamp:", "OPA1642AID (TI) — JFET-input, low-noise"),
|
||
|
|
("Circuit:", "AliceOPA Rev3 (inspired by DJJules / JLI Electronics)"),
|
||
|
|
("Architecture:", "2 independent PCBs (preamp + hex inverter)"),
|
||
|
|
("Power:", "Phantom 48V via XLR"),
|
||
|
|
("Connector:", "XLR 5-pin, balanced output"),
|
||
|
|
("Body:", "Aluminum (reconditioned)"),
|
||
|
|
("PCB design:", "KiCAD 8 — full project files available"),
|
||
|
|
("License:", "CERN-OHL-W-2.0 (irrevocable)"),
|
||
|
|
("Status:", "Assembled, awaiting debugging (Milestone 1)"),
|
||
|
|
]
|
||
|
|
for label, value in specs:
|
||
|
|
pdf.label_value(label, value)
|
||
|
|
pdf.ln(4)
|
||
|
|
|
||
|
|
# Capsule close-up
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/capsule_sc_600.jpg",
|
||
|
|
max_w=130, max_h=85,
|
||
|
|
caption="Fig. 3 — t.bone SC-600 capsule (34mm large-diaphragm condenser)"
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# PCB Photos (user's own)
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("3. My PCBs")
|
||
|
|
pdf.personal_text(
|
||
|
|
"Both boards were designed in KiCAD, fabricated by PCBWay, and hand-assembled "
|
||
|
|
"by me with SMD and through-hole components. The dual-PCB architecture means "
|
||
|
|
"if one component fails, you replace that component — not the whole microphone."
|
||
|
|
)
|
||
|
|
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/pcb_preamp.jpg",
|
||
|
|
max_w=100, max_h=100,
|
||
|
|
caption="Preamp board — OPA1642AID (my photograph)"
|
||
|
|
)
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/pcb_hex.jpg",
|
||
|
|
max_w=130, max_h=70,
|
||
|
|
caption="Hex inverter board — TC4584BF (my photograph)"
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# KiCAD schematics (user's own exports)
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("4. Circuit Design (KiCAD)")
|
||
|
|
|
||
|
|
# KiCAD screenshots — these ARE Nikola's own work
|
||
|
|
kicad_screens = [
|
||
|
|
(f"{CONCEPTION}/Screenshot From 2026-03-29 16-54-27.png",
|
||
|
|
"Fig. 4 — KiCAD PCB layouts: preamp board + hex inverter board"),
|
||
|
|
(f"{CONCEPTION}/mic_hex_inverter_pcb_prototype_p1/Screenshot From 2026-03-29 16-54-55.png",
|
||
|
|
"Fig. 5 — Hex inverter circuit schematic (KiCAD)"),
|
||
|
|
(f"{CONCEPTION}/mic_preamp_pcb_prototype_p1/Screenshot From 2026-03-29 16-55-17.png",
|
||
|
|
"Fig. 6 — Preamp circuit schematic (KiCAD)"),
|
||
|
|
]
|
||
|
|
for path, caption in kicad_screens:
|
||
|
|
pdf.try_image(path, max_w=170, max_h=90, caption=caption)
|
||
|
|
pdf.ln(1)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# Lab setup
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("5. My Lab (~3,000 EUR Self-Funded)")
|
||
|
|
pdf.personal_text(
|
||
|
|
"I built the complete measurement and fabrication lab with my own money. "
|
||
|
|
"Every instrument listed below was purchased specifically for this project. "
|
||
|
|
"This grant funds the work, not the tools — the tools are already here."
|
||
|
|
)
|
||
|
|
|
||
|
|
equipment = [
|
||
|
|
("Oscilloscope:", "Rigol DHO814 (12-bit, 100 MHz, 4-channel)"),
|
||
|
|
("Audio interface 1:", "Audient iD14 (reference-grade preamps)"),
|
||
|
|
("Audio interface 2:", "Behringer UMC-202HD (budget comparison)"),
|
||
|
|
("Reference mic 1:", "Rode NT1-A (studio standard)"),
|
||
|
|
("Reference mic 2:", "Power Edge Vibe C1 (budget baseline)"),
|
||
|
|
("Multimeter:", "Voltcraft VC-23"),
|
||
|
|
("Soldering:", "Toolcraft ST-100D (temperature controlled)"),
|
||
|
|
("Microscope:", "Perfex Sciences stereo (SMD inspection)"),
|
||
|
|
("Consumables:", "Lead-free solder, flux, desoldering — all RoHS"),
|
||
|
|
]
|
||
|
|
for label, value in equipment:
|
||
|
|
pdf.label_value(label, value)
|
||
|
|
pdf.ln(2)
|
||
|
|
|
||
|
|
pdf.try_image(
|
||
|
|
f"{PHOTOS}/lab_setup.jpg",
|
||
|
|
max_w=170, max_h=90,
|
||
|
|
caption="Fig. 7 — My workbench (oscilloscope, soldering station, microscope)"
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# BOM summary
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("6. Bill of Materials (Key Components)")
|
||
|
|
|
||
|
|
bom_items = [
|
||
|
|
("OPA1642AID", "Dual JFET op-amp (TI)", "SOIC-8", "Mouser"),
|
||
|
|
("TC4584BF", "Hex inverter (Toshiba)", "SOIC-14", "Mouser"),
|
||
|
|
("t.bone SC-600", "34mm LDC capsule", "Through-hole", "Thomann"),
|
||
|
|
("47\u03bcF/63V", "Electrolytic (Panasonic)", "Radial", "Mouser"),
|
||
|
|
("1G\u03A9", "Capsule bias resistor", "Axial", "Mouser"),
|
||
|
|
("TZX12D-TR", "12V Zener (Vishay)", "SOD-123", "Mouser"),
|
||
|
|
("1N4148", "Signal diode (onsemi)", "SOD-323", "Mouser"),
|
||
|
|
("Neutrik NC5MAV", "XLR 5-pin connector", "Panel mount", "Mouser/Thomann"),
|
||
|
|
]
|
||
|
|
|
||
|
|
pdf.set_font("Sans", "B", 8)
|
||
|
|
pdf.set_fill_color(240, 240, 240)
|
||
|
|
pdf.cell(38, 6, "Part", border=1, fill=True)
|
||
|
|
pdf.cell(48, 6, "Description", border=1, fill=True)
|
||
|
|
pdf.cell(25, 6, "Package", border=1, fill=True)
|
||
|
|
pdf.cell(35, 6, "Sourcing", border=1, fill=True, new_x="LMARGIN", new_y="NEXT")
|
||
|
|
|
||
|
|
pdf.set_font("Sans", "", 8)
|
||
|
|
for part, desc, pkg, src in bom_items:
|
||
|
|
pdf.cell(38, 5, part, border=1)
|
||
|
|
pdf.cell(48, 5, desc, border=1)
|
||
|
|
pdf.cell(25, 5, pkg, border=1)
|
||
|
|
pdf.cell(35, 5, src, border=1, new_x="LMARGIN", new_y="NEXT")
|
||
|
|
pdf.ln(2)
|
||
|
|
pdf.set_font("Sans", "I", 8)
|
||
|
|
pdf.set_text_color(100, 100, 100)
|
||
|
|
pdf.multi_cell(0, 4,
|
||
|
|
"Full BOM with exact quantities, prices, and sourcing links maintained as "
|
||
|
|
"structured spreadsheet. All components available from EU distributors."
|
||
|
|
)
|
||
|
|
pdf.ln(3)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# What grant funds
|
||
|
|
# =========================================================
|
||
|
|
pdf.section_title("7. What This Grant Funds (25,000 EUR)")
|
||
|
|
pdf.personal_text(
|
||
|
|
"Everything above this line I did on my own time and money. "
|
||
|
|
"The grant funds the path from assembled prototype to verified, "
|
||
|
|
"measured, and independently reproducible open commons."
|
||
|
|
)
|
||
|
|
|
||
|
|
steps = [
|
||
|
|
("DONE", "Circuit design (AliceOPA Rev3, inspired by DJJules)"),
|
||
|
|
("DONE", "KiCAD schematics and PCB layout (2 boards)"),
|
||
|
|
("DONE", "PCB fabrication (PCBWay) and hand-assembly"),
|
||
|
|
("DONE", "Component sourcing with complete BOM"),
|
||
|
|
("DONE", "Lab + infra purchased (~3K EUR self-funded)"),
|
||
|
|
("GRANT", "Circuit debugging and PCB revision if needed (M1)"),
|
||
|
|
("GRANT", "Capsule characterisation: multiple units + evaluate alternatives (M2)"),
|
||
|
|
("GRANT", "Open-source calibration toolkit in Rust (M3)"),
|
||
|
|
("GRANT", "Complete hardware publication under CERN-OHL-W (M4)"),
|
||
|
|
("GRANT", "Self-rebuild test from published files (M5)"),
|
||
|
|
("GRANT", "Reproducibility testing by 1-2 independent builders (M6)"),
|
||
|
|
("GRANT", "OSHWA registration"),
|
||
|
|
]
|
||
|
|
|
||
|
|
for status, desc in steps:
|
||
|
|
if status == "DONE":
|
||
|
|
pdf.set_font("Sans", "B", 9)
|
||
|
|
pdf.set_text_color(40, 140, 40)
|
||
|
|
pdf.cell(16, 5, "[DONE]")
|
||
|
|
else:
|
||
|
|
pdf.set_font("Sans", "B", 9)
|
||
|
|
pdf.set_text_color(180, 100, 20)
|
||
|
|
pdf.cell(16, 5, "[GRANT]")
|
||
|
|
pdf.set_font("Sans", "", 9)
|
||
|
|
pdf.set_text_color(40, 40, 40)
|
||
|
|
pdf.cell(0, 5, desc, new_x="LMARGIN", new_y="NEXT")
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# Output
|
||
|
|
# =========================================================
|
||
|
|
pdf.output(OUT)
|
||
|
|
print(f"\nPDF generated: {OUT}")
|
||
|
|
print(f"Pages: {pdf.pages_count}")
|
||
|
|
|
||
|
|
# Check for missing photos
|
||
|
|
expected = [
|
||
|
|
"prototype_assembled.jpg",
|
||
|
|
"mic_body_detached.jpg",
|
||
|
|
"capsule_sc_600.jpg",
|
||
|
|
"pcb_preamp.jpg",
|
||
|
|
"pcb_hex.jpg",
|
||
|
|
"lab_setup.jpg",
|
||
|
|
]
|
||
|
|
missing = [f for f in expected if not os.path.exists(f"{PHOTOS}/{f}")]
|
||
|
|
if missing:
|
||
|
|
print(f"\nMISSING PHOTOS ({len(missing)}):")
|
||
|
|
for f in missing:
|
||
|
|
print(f" photos/{f}")
|
||
|
|
print("Add these photos and run again for the complete PDF.")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|