[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

Rework the front page, and add a mobile theme toggle and language detection

Front page. The landing was the README feature list as ten cards of 60 to 90
words: it stated everything and showed nothing, and its numbers were an
earlier release's (371 checks, 235 standards, against the 427 over 278 the
conformance report now produces). It is now five blocks that each answer one
question: the numbers with no adjectives, what this is with who it is for and
an explicit list of what it is not, one worked example beside the figure it
draws and one rating beside the PDF fiche it renders, the coverage of nine
areas as a table of standards, and a three-step path from nothing to a first
analysis. Text lives in src/data/home.ts, one object per locale sharing one
shape, so English and Spanish cannot drift apart; every number in it is
sourced from the repository and the file records where.

The splash column widens to 62rem, the sidebar gutter Starlight reserves is
zeroed so the page is centred, and the hero grid collapses to one column
since there is no hero image.

Mobile header. Below the md breakpoint Starlight hides the theme control
behind the hamburger. Header.astro now surfaces it next to the language
picker, in the desktop order, as an icon-and-caret stub. Starlight's own
updatePickers() already walks every theme select on the page, so the mobile
stub and the desktop control stay in sync with no extra code.

Language detection. LangSuggest.astro offers the other language on a first
visit, in banner mode by default, with redirect and off available from the
prototype switcher. An explicit locale in the URL wins, the language picker
records the choice, and once anything is stored nothing ever navigates on its
own again, so a deliberately opened English URL is never hijacked.
Redirection is guarded by a stored choice, a session flag and a target check,
so it cannot loop; crawlers are skipped by user agent; the English-only API
subtree opts out entirely; and none of it touches canonical or hreflang.
scripts/check-lang-suggest.mjs drives eleven scenarios in isolated browser
contexts: 0 failing.

Build with link check, html-validate, pa11y at WCAG2AA (46/46) and the EN/ES
parity gate all pass.

José M. Requena Plens (Jul 26, 2026, 1:51 AM +0200) f87895e7 d51eb761

+1934 -252
+231 -1
SITE-REDESIGN-NOTES.md
··· 82 82 - Stamped **before first paint** by an inline script in 83 83 `src/components/Head.astro`, read from `localStorage['phonometry:accent']`, 84 84 so switching never flashes the previous palette. 85 - - Written by `src/components/AccentSwitcher.astro`, a small fixed control 85 + - Written by `src/components/PrototypeSwitcher.astro`, a small fixed control 86 86 mounted from `Footer.astro` (the one custom component that renders on every 87 87 page, splash included). Real `<button>`s in a labelled group with 88 88 `aria-pressed`, collapsed by default. ··· 94 94 95 95 Shipping means deleting: the winning block becomes the plain `:root` rule and 96 96 the switcher and the other two blocks go away. 97 + 98 + ### Recommendation 99 + 100 + **`instrument`.** It is the one that behaves best next to the thing the site is 101 + mostly made of, which is 1220 figure files I am not going to redraw: 102 + 103 + - Its dark ground is the compromise that suits both figure families at once: 104 + dark enough for the transparent plots (tab10 blue at 3.71:1) and neutral 105 + enough that the 102 diagram plates with their `#0d1117` ground do not read 106 + as a different material. `blueprint` is marginally better on the plate seam 107 + (1.03 against 1.06) but its navy ground is a strong colour opinion that a 108 + blue-heavy plot then has to compete with. 109 + - Cyan is far enough from `#1f77b4` and `#d62728` that a link never looks like 110 + a trace, which is exactly what the current Starlight blue-violet gets wrong. 111 + - Its greys are close to neutral, so the pages that are mostly tables and 112 + numbers read as paper rather than as a themed surface. 113 + 114 + `graphite` is the one I would keep as the runner-up, and the one to pick if 115 + the site should look unlike every other Starlight site at a glance: amber is 116 + the only accent here that is not blue, so nothing on the page competes with a 117 + plot at all. Its cost is the 102 dark diagrams, whose cool `#0d1117` plate is 118 + faintly visible against a warm ground; it is subtle, and only in dark mode, 119 + but it is there. 120 + 121 + `blueprint` is the weakest of the three for this site, not because it looks 122 + bad (it is arguably the handsomest in dark mode) but because it commits the 123 + whole page to the same hue family as the plots. 124 + 125 + ## 2. Front page 126 + 127 + The old landing was the feature list from the README rendered as ten cards of 128 + 60 to 90 words each. It states everything and shows nothing: a first-time 129 + visitor cannot tell what the library does with a signal, who it is for, or 130 + where to start, and the standards are buried inside prose. 131 + 132 + The new one, in `src/components/home/Home.astro` with all its text in 133 + `src/data/home.ts` (one object per locale, one shared shape so EN and ES 134 + cannot drift), answers one question per block: 135 + 136 + 1. **The numbers, first, without adjectives.** 427/427 conformance checks, 137 + 278 standards over 53 domains, 506 figures, 32 PDF fiches. Every one is 138 + taken from the repository (`docs/CONFORMANCE.md`, `.github/images`, 139 + `.github/reports`, `VERSION`), and the header comment in `home.ts` records 140 + where each came from. The old page said 371 checks and 235 standards, which 141 + were the numbers of an earlier release; the conformance report says 427 and 142 + 278. 143 + 2. **What this is**, in three paragraphs, plus **who it is for** and **what it 144 + is not**. The second list is the part I would not cut: it says in plain 145 + words that the conformance report is the library checking itself against 146 + published values and not an accredited calibration, that there is no 147 + hardware I/O, and that no standard is claimed to be implemented in full. 148 + A page that admits its limits is more credible about its claims. 149 + 3. **What it looks like in use**, showing rather than telling, with the 150 + artefacts the repository already has: eight lines of code beside the 151 + one-third-octave figure they produce (the committed figure, in the reader's 152 + language: the Spanish page shows the `_es` variant), then the ISO 717-1 153 + rating with the accredited-style PDF fiche it renders. 154 + 4. **What it covers**: nine areas as a table, each row linking to its area 155 + overview, with the standards as monospaced chips. Breadth becomes scannable 156 + instead of a wall of prose, and the chips are the visual signature of the 157 + page. 158 + 5. **Starting from zero**: three numbered steps, install, run one analysis end 159 + to end, go to your own domain. 160 + 161 + The blocks are numbered and ruled like the clauses of the documents the 162 + library implements, which is where the page gets its character from rather 163 + than from illustration. 164 + 165 + Layout notes: the splash column goes from 45rem to 62rem (the sidebar is 166 + hidden there anyway), the sidebar gutter that Starlight reserves is zeroed so 167 + the page is actually centred, the hero grid is collapsed to one column because 168 + there is no hero image, and Starlight's prose flow margin is switched off 169 + inside the landing, which is a layout and sets its own spacing. 170 + 171 + ## 3. Mobile theme toggle 172 + 173 + Below Starlight's `md` breakpoint the whole right-hand group of the header 174 + (social links, theme, language) is hidden and only reachable through the 175 + hamburger. The site already lifted the language picker out of it; 176 + `Header.astro` now lifts the theme control out too, immediately before the 177 + language picker, in the same desktop order. 178 + 179 + Both are rendered as icon-and-caret stubs: the same `width` trick the language 180 + picker already used (the visible label text is transparent, the icon and caret 181 + remain), so the bar still fits `logo · search · theme · language · menu` at 182 + 360 px. Nothing about the desktop header changes, and nothing about 183 + Starlight's own `ThemeSelect` behaviour changes either: `updatePickers()` 184 + already walks *every* `starlight-theme-select` on the page, so the mobile stub 185 + and the desktop select stay in sync by construction, and each instance wires 186 + its own `change` listener. The accessible name comes from the visually hidden 187 + `<span>` inside the label, so hiding the visible text costs nothing, and the 188 + icon itself still reports the current state (sun, moon, laptop). 189 + 190 + ## 4. Language auto-detection 191 + 192 + `src/components/LangSuggest.astro`. Three modes, chosen at runtime from 193 + `localStorage['phonometry:lang-mode']` (the prototype switcher writes it) and 194 + defaulting to the component's `DEFAULT_MODE`: 195 + 196 + | Mode | Behaviour | 197 + | :-- | :-- | 198 + | `banner` (default) | A one-line dismissible bar pinned to the bottom edge, written in the language it offers. Nothing navigates on its own. | 199 + | `redirect` | One `location.replace` to the counterpart page, on a genuine first visit only. | 200 + | `off` | Nothing runs. | 201 + 202 + ### Rules that hold in every mode 203 + 204 + - **An explicit locale in the URL wins.** `?lang=en` or `?lang=es` is recorded 205 + as the visitor's choice and silences the suggestion. 206 + - **Using the language picker records the choice.** A delegated capture-phase 207 + `change` listener on `starlight-lang-select` stores `en` or `es` before 208 + Starlight navigates, so a manual decision beats `Accept-Language` from then 209 + on. Dismissing the banner records the current page's language for the same 210 + reason: it is also a decision. 211 + - **Once anything is stored, nothing ever navigates on its own again.** This is 212 + the rule that keeps the feature from trapping anyone: someone who chose 213 + Spanish and then deliberately opens an English URL stays on the English URL 214 + and is at most offered a link. 215 + - **Redirection is guarded three ways**: only with nothing stored, only once 216 + per session (`sessionStorage`), and only when the target differs from the 217 + current path. It cannot loop, and it uses `replace` so the back button 218 + returns to wherever the visitor came from instead of bouncing. 219 + - **Crawlers are skipped** by user agent, and nothing here touches 220 + `rel=canonical` or the `hreflang` alternates, which is what search engines 221 + actually use to pick a locale (the site already emits `en`, `es` and 222 + `x-default` on every page). 223 + - **The API reference is excluded entirely.** It is generated in English only 224 + and served under `/es/` by Starlight's locale fallback, so the banner is not 225 + even rendered in that subtree: offering a Spanish speaker a page that is 226 + still English, with a fallback notice on it, would be worse than silence. 227 + - Nothing is inferred from a language the visitor does not have: if neither 228 + `es` nor `en` appears in `navigator.languages`, the feature does nothing. 229 + 230 + `site/scripts/check-lang-suggest.mjs` drives all eleven scenarios in isolated 231 + browser contexts and asserts the resulting URL, banner state and stored value: 232 + 233 + ``` 234 + $ node scripts/check-lang-suggest.mjs 235 + 0 failing scenario(s). 236 + ``` 237 + 238 + ### Redirect or banner: the recommendation 239 + 240 + **The banner, and I would ship it that way.** The arguments are specific to 241 + this site, not general: 242 + 243 + 1. **English URLs here are deliberate, not default.** English is the root 244 + locale, so `/phonometry/guides/levels/` is a real canonical URL. It is what 245 + the README, PyPI, Zenodo, an answer on a forum and every English search 246 + result point at. A visitor who followed one of those made a choice about 247 + the page, not just about a language; silently replacing it overrides them. 248 + 2. **A redirect is worst exactly where the content is thinnest.** The 120 249 + generated API pages exist only in English. Sending a Spanish-preferring 250 + visitor into `/es/` there hands them an English page with a fallback notice 251 + instead of the canonical one. The banner has no such failure mode, and I do 252 + not have to special-case a subtree for it (although it opts out of that 253 + subtree anyway). 254 + 3. **It leaves the search engines alone.** The site already declares 255 + `hreflang` for both locales and `x-default` for English, which is the 256 + supported mechanism, and Google explicitly prefers server-side or link-based 257 + handling over client-side redirection. A client-side hop on a static host 258 + is the one variant that risks being read as a redirect for some crawlers 259 + and not others, for no gain the `hreflang` links do not already give. 260 + 4. **It teaches instead of switching.** A redirect never tells the reader that 261 + the other language exists or how to get back; the bar names both the 262 + language and the way back, and one dismissal ends it forever. 263 + 5. **The cost is one click**, on a two-language site with a visible picker in 264 + the header, and now on phones too. 265 + 266 + The case for `redirect` is that it is fewer clicks for the Spanish-speaking 267 + audience that the Spanish mirror exists for, and it is implemented and 268 + switchable here so it can be tried. But it is the mode that can be wrong, and 269 + the banner is the mode that cannot. 270 + 271 + ## Screenshots 272 + 273 + All captured by `site/scripts/redesign-shots.mjs`, which drives its own 274 + headless Chrome so each shot states its viewport, locale, colour direction and 275 + theme rather than depending on a live browser session. Desktop is 1440x900, 276 + narrow is 390x844. 277 + 278 + ``` 279 + pnpm --dir site dev --host 0.0.0.0 --port 4322 280 + node site/scripts/redesign-shots.mjs # all of them 281 + node site/scripts/redesign-shots.mjs 20-home # or a subset, by name 282 + ``` 283 + 284 + | File | What it shows | 285 + | :-- | :-- | 286 + | `00-baseline-home-desktop-light.png` | The landing page before any of this: Starlight defaults, ten description cards. | 287 + | `10-guide-instrument-dark.png` … `15-guide-graphite-light.png` | The three directions, light and dark, on a guide with a hand-drawn diagram (the `#0d1117` plate case). | 288 + | `16-plot-instrument-dark.png` … `19-plot-instrument-light.png` | The same three directions on a page whose figure is a matplotlib plot (the transparent case). | 289 + | `20-home-instrument-dark.png`, `21-home-instrument-light.png` | The new landing, recommended direction, both themes, full page. | 290 + | `22-home-blueprint-dark.png`, `23-home-graphite-light.png` | The new landing in the other two directions. | 291 + | `24-home-es-instrument-light.png`, `25-home-es-blueprint-dark.png` | The Spanish landing, including the Spanish variant of the figure. | 292 + | `30-home-phone-instrument-dark.png`, `31-home-phone-graphite-light.png` | The landing at 390 px. | 293 + | `32-guide-phone-instrument-dark.png` | A guide at 390 px. | 294 + | `33-header-phone-light.png`, `34-header-phone-dark.png` | The mobile header with the new theme control, EN light and ES dark. | 295 + | `40-lang-banner-on-en-page.png` | The language bar on an English page for a Spanish browser, desktop. | 296 + | `41-lang-banner-on-es-page-phone.png` | The language bar on a Spanish page for an English browser, phone. | 297 + | `42-prototype-switcher-open.png` | The prototype switcher open, with both groups. | 298 + 299 + ## Gates 300 + 301 + Run against this branch, all green: 302 + 303 + | Gate | Command | Result | 304 + | :-- | :-- | :-- | 305 + | Build and link check | `pnpm --dir site build` | 441 pages, all internal links valid | 306 + | HTML validation | `pnpm --dir site html-validate` | clean | 307 + | Accessibility | `pa11y-ci` (WCAG2AA, 46 URLs) | 46/46 passed | 308 + | EN/ES parity | `node site/scripts/check-i18n-parity.mjs` | 100 EN pages each have an ES translation | 309 + | Contrast | `node site/scripts/check-contrast.mjs` | 87 pairs, 0 enforced pair below threshold | 310 + | Language handling | `node site/scripts/check-lang-suggest.mjs` | 11 scenarios, 0 failing | 311 + 312 + The pa11y run used a copy of `.pa11yci.json` on port 4323, because 4321 was 313 + busy with the other experimental branch's dev server; the URL list is 314 + otherwise identical. 315 + 316 + ## What is not done here 317 + 318 + - The three directions are all still present. Shipping means keeping one, 319 + promoting it to the plain `:root` block and deleting `PrototypeSwitcher`, 320 + the other two blocks and the `data-accent` script in `Head.astro`. 321 + - The landing quotes 427/278/53 from `docs/CONFORMANCE.md`. The README still 322 + says 371/235/46 in its own prose; that is a separate fix, on main. 323 + - The ISO 717 fiche preview on the landing is the English one in both 324 + locales, exactly as the guide embeds it today. Rendering a Spanish fiche 325 + would mean regenerating the reports, which is a `make reports` job and not a 326 + site change.
redesign-shots/10-guide-instrument-dark.png

This is a binary file and will not be displayed.

redesign-shots/11-guide-instrument-light.png

This is a binary file and will not be displayed.

redesign-shots/12-guide-blueprint-dark.png

This is a binary file and will not be displayed.

redesign-shots/13-guide-blueprint-light.png

This is a binary file and will not be displayed.

redesign-shots/14-guide-graphite-dark.png

This is a binary file and will not be displayed.

redesign-shots/15-guide-graphite-light.png

This is a binary file and will not be displayed.

redesign-shots/16-plot-instrument-dark.png

This is a binary file and will not be displayed.

redesign-shots/17-plot-blueprint-dark.png

This is a binary file and will not be displayed.

redesign-shots/18-plot-graphite-dark.png

This is a binary file and will not be displayed.

redesign-shots/19-plot-instrument-light.png

This is a binary file and will not be displayed.

redesign-shots/20-home-instrument-dark.png

This is a binary file and will not be displayed.

redesign-shots/21-home-instrument-light.png

This is a binary file and will not be displayed.

redesign-shots/22-home-blueprint-dark.png

This is a binary file and will not be displayed.

redesign-shots/23-home-graphite-light.png

This is a binary file and will not be displayed.

redesign-shots/24-home-es-instrument-light.png

This is a binary file and will not be displayed.

redesign-shots/25-home-es-blueprint-dark.png

This is a binary file and will not be displayed.

redesign-shots/30-home-phone-instrument-dark.png

This is a binary file and will not be displayed.

redesign-shots/31-home-phone-graphite-light.png

This is a binary file and will not be displayed.

redesign-shots/32-guide-phone-instrument-dark.png

This is a binary file and will not be displayed.

redesign-shots/33-header-phone-light.png

This is a binary file and will not be displayed.

redesign-shots/34-header-phone-dark.png

This is a binary file and will not be displayed.

redesign-shots/40-lang-banner-on-en-page.png

This is a binary file and will not be displayed.

redesign-shots/41-lang-banner-on-es-page-phone.png

This is a binary file and will not be displayed.

redesign-shots/42-prototype-switcher-open.png

This is a binary file and will not be displayed.

+1
site/astro.config.mjs
··· 263 263 './src/styles/theme-images.css', 264 264 './src/styles/theme-tables.css', 265 265 './src/styles/splash-menu.css', 266 + './src/styles/home.css', 266 267 './src/styles/sidebar.css', 267 268 ], 268 269 social: [
+158
site/scripts/check-lang-suggest.mjs
··· 1 + // Behaviour checks for the first-visit language handling. 2 + // 3 + // Runs the scenarios that decide whether the feature is safe: a first visit 4 + // with a Spanish browser, a visitor who deliberately opened an English URL 5 + // after choosing Spanish, an explicit ?lang=, a dismissal, a crawler, the 6 + // English-only API subtree, and both redirect guards (once per session, never 7 + // after a stored choice). 8 + // 9 + // Usage: node scripts/check-lang-suggest.mjs [--base http://localhost:4322] 10 + import { readdirSync } from 'node:fs'; 11 + import { createRequire } from 'node:module'; 12 + import { dirname, join } from 'node:path'; 13 + import { fileURLToPath } from 'node:url'; 14 + 15 + const siteDir = join(dirname(fileURLToPath(import.meta.url)), '..'); 16 + const require = createRequire(import.meta.url); 17 + const store = join(siteDir, 'node_modules', '.pnpm'); 18 + const pkg = readdirSync(store).find((d) => /^puppeteer@/.test(d)); 19 + const puppeteer = require(join(store, pkg, 'node_modules', 'puppeteer')); 20 + 21 + const baseIdx = process.argv.indexOf('--base'); 22 + const BASE = baseIdx === -1 ? 'http://localhost:4322' : process.argv[baseIdx + 1]; 23 + 24 + const browser = await puppeteer.launch({ 25 + headless: true, 26 + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], 27 + }); 28 + 29 + let failures = 0; 30 + const expect = (name, actual, wanted) => { 31 + const ok = JSON.stringify(actual) === JSON.stringify(wanted); 32 + if (!ok) failures++; 33 + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}\n got ${JSON.stringify(actual)}, want ${JSON.stringify(wanted)}`); 34 + }; 35 + 36 + /** 37 + * Load `path` with a fake language list, an optional pre-seeded localStorage 38 + * and an optional user agent, and report what the page decided. 39 + */ 40 + async function visit(path, { languages = ['es-ES', 'es'], seed = {}, ua, mode } = {}) { 41 + // Every scenario gets its own browser context, otherwise localStorage 42 + // written by one of them leaks into the next and the results are garbage. 43 + const context = await browser.createBrowserContext(); 44 + const page = await context.newPage(); 45 + if (ua) await page.setUserAgent(ua); 46 + await page.evaluateOnNewDocument( 47 + (langs, seeded, m) => { 48 + Object.defineProperty(navigator, 'languages', { get: () => langs }); 49 + Object.defineProperty(navigator, 'language', { get: () => langs[0] }); 50 + try { 51 + for (const [k, v] of Object.entries(seeded)) localStorage.setItem(k, v); 52 + if (m) localStorage.setItem('phonometry:lang-mode', m); 53 + } catch {} 54 + }, 55 + languages, 56 + seed, 57 + mode, 58 + ); 59 + await page.goto(`${BASE}/phonometry${path}`, { waitUntil: 'networkidle0', timeout: 60000 }); 60 + await new Promise((r) => setTimeout(r, 400)); 61 + const result = await page.evaluate(() => { 62 + const b = document.getElementById('lang-suggest'); 63 + return { 64 + url: location.pathname, 65 + banner: b ? !b.hidden : 'absent', 66 + stored: localStorage.getItem('phonometry:lang'), 67 + }; 68 + }); 69 + await page.close(); 70 + await context.close(); 71 + return result; 72 + } 73 + 74 + // 1. First visit, Spanish browser, English page: the banner offers Spanish 75 + // and nothing navigates. 76 + expect('banner: first visit, es browser on an EN page', await visit('/guides/levels/'), { 77 + url: '/phonometry/guides/levels/', 78 + banner: true, 79 + stored: null, 80 + }); 81 + 82 + // 2. Same page, English browser: silent. 83 + expect( 84 + 'banner: en browser on an EN page stays silent', 85 + await visit('/guides/levels/', { languages: ['en-GB', 'en'] }), 86 + { url: '/phonometry/guides/levels/', banner: false, stored: null }, 87 + ); 88 + 89 + // 3. Spanish page, English browser: the banner offers English. 90 + expect( 91 + 'banner: en browser on an ES page', 92 + await visit('/es/guides/levels/', { languages: ['en-US', 'en'] }), 93 + { url: '/phonometry/es/guides/levels/', banner: true, stored: null }, 94 + ); 95 + 96 + // 4. A stored Spanish choice does not fire on an English URL the visitor 97 + // opened deliberately: it offers, it never navigates. 98 + expect( 99 + 'no trap: stored es choice on an EN url only offers', 100 + await visit('/guides/levels/', { seed: { 'phonometry:lang': 'es' } }), 101 + { url: '/phonometry/guides/levels/', banner: true, stored: 'es' }, 102 + ); 103 + 104 + // 5. An explicit ?lang=en is a decision: recorded, and silent. 105 + expect( 106 + 'explicit ?lang=en wins over the browser list', 107 + await visit('/guides/levels/?lang=en'), 108 + { url: '/phonometry/guides/levels/', banner: false, stored: 'en' }, 109 + ); 110 + 111 + // 6. A previous dismissal keeps it quiet. 112 + expect( 113 + 'dismissed stays dismissed', 114 + await visit('/guides/levels/', { seed: { 'phonometry:lang-dismissed': '1' } }), 115 + { url: '/phonometry/guides/levels/', banner: false, stored: null }, 116 + ); 117 + 118 + // 7. Crawlers see nothing. 119 + expect( 120 + 'crawler user agent is skipped', 121 + await visit('/guides/levels/', { 122 + ua: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 123 + }), 124 + { url: '/phonometry/guides/levels/', banner: false, stored: null }, 125 + ); 126 + 127 + // 8. The English-only API subtree opts out entirely. 128 + expect( 129 + 'API reference renders no banner at all', 130 + await visit('/reference/api/levels/levels/'), 131 + { url: '/phonometry/reference/api/levels/levels/', banner: 'absent', stored: null }, 132 + ); 133 + 134 + // 9. Redirect mode, first visit with nothing stored: one hop to /es/. 135 + expect( 136 + 'redirect: first visit hops once', 137 + await visit('/guides/levels/', { mode: 'redirect' }), 138 + { url: '/phonometry/es/guides/levels/', banner: false, stored: null }, 139 + ); 140 + 141 + // 10. Redirect mode with a stored choice: degrades to the banner, no hop. 142 + expect( 143 + 'redirect: a stored choice never navigates', 144 + await visit('/guides/levels/', { mode: 'redirect', seed: { 'phonometry:lang': 'es' } }), 145 + { url: '/phonometry/guides/levels/', banner: true, stored: 'es' }, 146 + ); 147 + 148 + // 11. Redirect mode on the page it would redirect to: nothing to do, and the 149 + // session guard means a second load cannot bounce back. 150 + expect( 151 + 'redirect: no loop on the target page', 152 + await visit('/es/guides/levels/', { mode: 'redirect' }), 153 + { url: '/phonometry/es/guides/levels/', banner: false, stored: null }, 154 + ); 155 + 156 + await browser.close(); 157 + console.log(`\n${failures} failing scenario(s).`); 158 + process.exit(failures ? 1 : 0);
+83
site/scripts/redesign-shots.mjs
··· 63 63 ['34-header-phone-dark', '/es/getting-started/', PHONE, 'blueprint', 'dark', 0], 64 64 ]; 65 65 66 + /** 67 + * Shots that need something more than a viewport and a palette: a faked 68 + * Accept-Language list, or a click before the capture. 69 + */ 70 + const EXTRA = [ 71 + { 72 + name: '40-lang-banner-on-en-page', 73 + path: '/guides/levels/', 74 + viewport: DESKTOP, 75 + accent: 'instrument', 76 + theme: 'light', 77 + languages: ['es-ES', 'es'], 78 + }, 79 + { 80 + name: '41-lang-banner-on-es-page-phone', 81 + path: '/es/guides/levels/', 82 + viewport: PHONE, 83 + accent: 'blueprint', 84 + theme: 'dark', 85 + languages: ['en-US', 'en'], 86 + }, 87 + { 88 + name: '42-prototype-switcher-open', 89 + path: '/', 90 + viewport: DESKTOP, 91 + accent: 'graphite', 92 + theme: 'dark', 93 + click: '[data-accent-toggle]', 94 + }, 95 + ]; 96 + 66 97 await mkdir(OUT, { recursive: true }); 67 98 68 99 const browser = await puppeteer.launch({ ··· 89 120 await page.evaluate((t) => { 90 121 document.documentElement.dataset.theme = t; 91 122 }, theme); 123 + // The dev server injects the Astro toolbar; it is not part of the design. 124 + await page.addStyleTag({ content: 'astro-dev-toolbar{display:none !important}' }); 125 + if (fullPage) { 126 + // Walk the page so lazily-loaded figures and fiche previews are decoded 127 + // before the full-page capture, then go back to the top. 128 + await page.evaluate(async () => { 129 + const step = window.innerHeight * 0.8; 130 + for (let y = 0; y < document.body.scrollHeight; y += step) { 131 + window.scrollTo(0, y); 132 + await new Promise((r) => setTimeout(r, 120)); 133 + } 134 + window.scrollTo(0, 0); 135 + }); 136 + await new Promise((r) => setTimeout(r, 1200)); 137 + } 92 138 if (scrollY) { 93 139 await page.evaluate((y) => window.scrollTo(0, y), scrollY); 94 140 await new Promise((r) => setTimeout(r, 400)); ··· 96 142 await page.screenshot({ path: join(OUT, `${name}.png`), fullPage }); 97 143 await page.close(); 98 144 console.log(`captured ${name}`); 145 + } 146 + 147 + for (const shot of EXTRA) { 148 + if (filters.length && !filters.some((f) => shot.name.includes(f))) continue; 149 + // A fresh context per shot, so a previous scenario's localStorage cannot 150 + // change what this one shows. 151 + const context = await browser.createBrowserContext(); 152 + const page = await context.newPage(); 153 + await page.setViewport({ ...shot.viewport, deviceScaleFactor: 1 }); 154 + await page.evaluateOnNewDocument( 155 + (a, t, langs) => { 156 + try { 157 + localStorage.setItem('phonometry:accent', a); 158 + localStorage.setItem('starlight-theme', t); 159 + } catch {} 160 + if (langs) { 161 + Object.defineProperty(navigator, 'languages', { get: () => langs }); 162 + Object.defineProperty(navigator, 'language', { get: () => langs[0] }); 163 + } 164 + }, 165 + shot.accent, 166 + shot.theme, 167 + shot.languages ?? null, 168 + ); 169 + await page.goto(`${BASE}/phonometry${shot.path}`, { waitUntil: 'networkidle0', timeout: 90000 }); 170 + await page.evaluate((t) => { 171 + document.documentElement.dataset.theme = t; 172 + }, shot.theme); 173 + await page.addStyleTag({ content: 'astro-dev-toolbar{display:none !important}' }); 174 + if (shot.click) { 175 + await page.click(shot.click); 176 + await new Promise((r) => setTimeout(r, 300)); 177 + } 178 + await new Promise((r) => setTimeout(r, 500)); 179 + await page.screenshot({ path: join(OUT, `${shot.name}.png`) }); 180 + await context.close(); 181 + console.log(`captured ${shot.name}`); 99 182 } 100 183 101 184 await browser.close();
-169
site/src/components/AccentSwitcher.astro
··· 1 - --- 2 - /** 3 - * Experimental colour-direction switcher. 4 - * 5 - * Three complete palettes live in src/styles/theme-directions.css, keyed on a 6 - * `data-accent` attribute on <html>. The attribute is stamped before first 7 - * paint by the inline script in Head.astro (so there is no flash) and read 8 - * back from localStorage under `phonometry:accent`; this control only writes 9 - * it. It is a prototype affordance for choosing a direction, not a feature 10 - * meant to ship: once a direction is picked, the winning block becomes the 11 - * unconditional `:root` rule and this component goes away. 12 - * 13 - * Rendered from Footer.astro so it exists on every page, splash included, and 14 - * positioned fixed. The buttons are real <button>s in a labelled group with 15 - * `aria-pressed`, so the control is reachable and announced; the panel starts 16 - * collapsed on narrow viewports to stay out of the reading column. 17 - */ 18 - const DIRECTIONS = [ 19 - { id: 'instrument', label: 'Instrument' }, 20 - { id: 'blueprint', label: 'Blueprint' }, 21 - { id: 'graphite', label: 'Graphite' }, 22 - ] as const; 23 - --- 24 - 25 - <div class="accent-switcher" id="accent-switcher"> 26 - <button 27 - type="button" 28 - class="accent-toggle" 29 - aria-expanded="false" 30 - aria-controls="accent-options" 31 - data-accent-toggle 32 - > 33 - <span class="accent-toggle-dot" aria-hidden="true"></span> 34 - <span class="accent-toggle-label">Colour</span> 35 - </button> 36 - <div class="accent-options" id="accent-options" role="group" aria-label="Colour direction" hidden> 37 - { 38 - DIRECTIONS.map((d) => ( 39 - <button type="button" class="accent-option" data-accent-set={d.id} aria-pressed="false"> 40 - <span class={`accent-swatch accent-swatch-${d.id}`} aria-hidden="true" /> 41 - {d.label} 42 - </button> 43 - )) 44 - } 45 - </div> 46 - </div> 47 - 48 - <script is:inline> 49 - (() => { 50 - const root = document.documentElement; 51 - const KEY = 'phonometry:accent'; 52 - const panel = document.getElementById('accent-switcher'); 53 - if (!panel) return; 54 - const options = panel.querySelector('#accent-options'); 55 - const toggle = panel.querySelector('[data-accent-toggle]'); 56 - 57 - const sync = () => { 58 - const active = root.dataset.accent || 'instrument'; 59 - for (const btn of panel.querySelectorAll('[data-accent-set]')) { 60 - btn.setAttribute('aria-pressed', String(btn.dataset.accentSet === active)); 61 - } 62 - }; 63 - 64 - for (const btn of panel.querySelectorAll('[data-accent-set]')) { 65 - btn.addEventListener('click', () => { 66 - root.dataset.accent = btn.dataset.accentSet; 67 - try { 68 - localStorage.setItem(KEY, btn.dataset.accentSet); 69 - } catch { 70 - /* private mode: the choice just does not survive the tab */ 71 - } 72 - sync(); 73 - }); 74 - } 75 - 76 - toggle?.addEventListener('click', () => { 77 - const open = toggle.getAttribute('aria-expanded') === 'true'; 78 - toggle.setAttribute('aria-expanded', String(!open)); 79 - if (options) options.hidden = open; 80 - }); 81 - 82 - sync(); 83 - })(); 84 - </script> 85 - 86 - <style> 87 - .accent-switcher { 88 - position: fixed; 89 - inset-block-end: 0.75rem; 90 - inset-inline-end: 0.75rem; 91 - z-index: 50; 92 - display: flex; 93 - flex-direction: column; 94 - align-items: flex-end; 95 - gap: 0.375rem; 96 - font-family: var(--__sl-font-mono); 97 - font-size: var(--sl-text-2xs); 98 - line-height: 1; 99 - } 100 - 101 - .accent-toggle, 102 - .accent-option { 103 - display: flex; 104 - align-items: center; 105 - gap: 0.5rem; 106 - padding: 0.4rem 0.6rem; 107 - border: 1px solid var(--ph-border); 108 - border-radius: var(--ph-radius, 4px); 109 - background: var(--ph-surface-raised, var(--sl-color-bg)); 110 - color: var(--sl-color-gray-2); 111 - cursor: pointer; 112 - text-transform: uppercase; 113 - letter-spacing: 0.06em; 114 - box-shadow: var(--sl-shadow-sm); 115 - } 116 - 117 - .accent-toggle:hover, 118 - .accent-option:hover { 119 - border-color: var(--ph-border-strong); 120 - color: var(--sl-color-white); 121 - } 122 - 123 - .accent-toggle-dot { 124 - width: 0.55rem; 125 - height: 0.55rem; 126 - border-radius: 50%; 127 - background: var(--ph-mark); 128 - } 129 - 130 - .accent-options { 131 - display: flex; 132 - flex-direction: column; 133 - align-items: stretch; 134 - gap: 0.25rem; 135 - } 136 - /* `display: flex` would otherwise beat the user-agent [hidden] rule. */ 137 - .accent-options[hidden] { 138 - display: none; 139 - } 140 - 141 - .accent-option[aria-pressed='true'] { 142 - border-color: var(--ph-mark); 143 - color: var(--sl-color-white); 144 - } 145 - 146 - .accent-swatch { 147 - width: 0.75rem; 148 - height: 0.75rem; 149 - border-radius: 2px; 150 - border: 1px solid var(--ph-border-strong); 151 - } 152 - /* Fixed swatch colours: each one previews its own direction, so they 153 - cannot follow the active palette's tokens. */ 154 - .accent-swatch-instrument { 155 - background: linear-gradient(135deg, #0a6f8c 50%, #35b8d8 50%); 156 - } 157 - .accent-swatch-blueprint { 158 - background: linear-gradient(135deg, #23479a 50%, #78a2f0 50%); 159 - } 160 - .accent-swatch-graphite { 161 - background: linear-gradient(135deg, #8a4b06 50%, #e0a33e 50%); 162 - } 163 - 164 - @media print { 165 - .accent-switcher { 166 - display: none; 167 - } 168 - } 169 - </style>
+8 -4
site/src/components/Footer.astro
··· 3 3 // human-visible maintainer block that corroborates the Person node in the 4 4 // site-wide JSON-LD @graph (GEO: entity corroboration). 5 5 import Default from '@astrojs/starlight/components/Footer.astro'; 6 - // Experimental: floating colour-direction switcher. Mounted here because the 7 - // footer is the one custom component rendered on every page, splash included. 8 - import AccentSwitcher from './AccentSwitcher.astro'; 6 + // Experimental: the floating prototype switcher (colour direction, language 7 + // detection mode) and the first-visit language suggestion. Both are mounted 8 + // here because the footer is the one custom component rendered on every page, 9 + // splash included; both position themselves fixed. 10 + import PrototypeSwitcher from './PrototypeSwitcher.astro'; 11 + import LangSuggest from './LangSuggest.astro'; 9 12 10 13 const path = Astro.url.pathname; 11 14 const lang = path.includes('/es/') || path.endsWith('/es') ? 'es' : 'en'; ··· 22 25 · <a href="https://github.com/jmrplens/phonometry">GitHub</a> 23 26 · <a href="https://pypi.org/project/phonometry/">PyPI</a> 24 27 </div> 25 - <AccentSwitcher /> 28 + <LangSuggest /> 29 + <PrototypeSwitcher /> 26 30 27 31 <style> 28 32 .maintainer {
+19 -6
site/src/components/Header.astro
··· 21 21 <div class="sl-flex print:hidden"> 22 22 {shouldRenderSearch && <Search />} 23 23 </div> 24 - <div class="sl-flex md:sl-hidden print:hidden mobile-lang"> 24 + <div class="sl-flex md:sl-hidden print:hidden mobile-controls"> 25 + <ThemeSelect /> 25 26 <LanguageSelect /> 26 27 </div> 27 28 <div class="sl-hidden md:sl-flex print:hidden right-group"> ··· 61 62 border-inline-end: 1px solid var(--sl-color-gray-5); 62 63 } 63 64 64 - .mobile-lang { 65 + /* Below the md breakpoint Starlight hides the whole right group behind 66 + the hamburger, which buries both the language picker and the theme 67 + control. Both are surfaced here instead, in the desktop order 68 + (theme, then language), as icon-and-caret stubs: the wordy label is 69 + hidden so the narrow bar (logo · search · theme · language · menu) 70 + still fits on a 360 px phone. The accessible name is unaffected, 71 + since it comes from the visually-hidden <span> inside each label, 72 + and the theme icon itself still reports the current state. */ 73 + .mobile-controls { 65 74 align-items: center; 66 - /* Keep the compact icon + caret, drop the wordy label so the 67 - narrow bar (logo · search · language · menu) still fits. */ 75 + gap: 0.25rem; 68 76 } 69 - .mobile-lang :global(select) { 77 + .mobile-controls :global(select) { 70 78 width: 3.25rem; 71 79 padding-inline: 0.25rem; 72 80 color: transparent; /* hide the selected-label text, keep icon/caret */ 73 81 } 74 - .mobile-lang :global(option) { 82 + .mobile-controls :global(option) { 75 83 color: var(--sl-color-text); 84 + } 85 + /* The theme stub is a hair narrower: its caret and icon are the same 86 + size but it never has to hint at a longer word behind them. */ 87 + .mobile-controls :global(starlight-theme-select select) { 88 + width: 2.75rem; 76 89 } 77 90 78 91 @media (min-width: 50rem) {
+279
site/src/components/LangSuggest.astro
··· 1 + --- 2 + /** 3 + * First-visit language handling, client side only. 4 + * 5 + * The site is a static export on GitHub Pages: there is no server, no 6 + * middleware and no Vary: Accept-Language, so anything that reacts to the 7 + * visitor's language has to happen in the browser. Two behaviours are 8 + * implemented here and one of them is chosen at runtime: 9 + * 10 + * banner (default) a quiet, dismissible one-line bar offering the other 11 + * language. Nothing navigates on its own. 12 + * redirect a single `location.replace` to the counterpart page, and only 13 + * on a genuine first visit with no stored preference. 14 + * off nothing at all. 15 + * 16 + * The mode is `DEFAULT_MODE` below, overridable at runtime with 17 + * `localStorage['phonometry:lang-mode']` (the prototype switcher writes it), 18 + * so it can be turned off or flipped without a rebuild. 19 + * 20 + * Rules that hold in every mode: 21 + * 22 + * - An explicit locale in the URL always wins. `?lang=en` or `?lang=es` 23 + * is recorded as the visitor's choice and suppresses any suggestion. 24 + * - Using the language picker records the choice, so a manual decision 25 + * beats the browser's Accept-Language list from then on. 26 + * - Once a choice is stored, the page NEVER navigates on its own again. 27 + * Someone who deliberately opens an English URL after choosing Spanish 28 + * stays on the English URL and is at most offered a link. This is what 29 + * keeps the feature from trapping anyone. 30 + * - Redirection is guarded three ways: only when nothing is stored, only 31 + * once per session (sessionStorage), and only when the target path 32 + * differs from the current one, so it cannot loop. 33 + * - Known crawler user agents are skipped, and nothing here touches the 34 + * canonical or hreflang links, which is what search engines actually 35 + * use to pick a locale. 36 + * - The API reference is generated in English only and served under /es/ 37 + * by Starlight's locale fallback, so the whole subtree is excluded: 38 + * sending a Spanish speaker to an English page with a fallback notice 39 + * would be worse than leaving them where they are. 40 + * 41 + * The banner is rendered in the language it offers, since that is the 42 + * language the reader is assumed to understand. 43 + */ 44 + 45 + /** 'banner' | 'redirect' | 'off' */ 46 + const DEFAULT_MODE = 'banner'; 47 + 48 + const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); 49 + const pathname = Astro.url.pathname; 50 + const rel = pathname.startsWith(BASE) ? pathname.slice(BASE.length) : pathname; 51 + const isEs = rel === '/es' || rel.startsWith('/es/'); 52 + const current: 'en' | 'es' = isEs ? 'es' : 'en'; 53 + 54 + // English-only generated subtree: no useful counterpart exists. 55 + const bare = isEs ? rel.replace(/^\/es/, '') || '/' : rel; 56 + const isApiReference = bare.startsWith('/reference/api'); 57 + 58 + // Counterpart URL under this site's scheme: ES mirrors EN under /es/. 59 + const counterpart = isEs ? `${BASE}${bare}` : `${BASE}/es${bare === '/' ? '/' : bare}`; 60 + 61 + const COPY = { 62 + // Shown on an English page, in Spanish, offering Spanish. 63 + es: { 64 + text: 'Esta página también está disponible en español.', 65 + action: 'Ver en español', 66 + dismiss: 'Cerrar', 67 + region: 'Sugerencia de idioma', 68 + }, 69 + // Shown on a Spanish page, in English, offering English. 70 + en: { 71 + text: 'This page is also available in English.', 72 + action: 'View in English', 73 + dismiss: 'Dismiss', 74 + region: 'Language suggestion', 75 + }, 76 + } as const; 77 + 78 + const other: 'en' | 'es' = current === 'en' ? 'es' : 'en'; 79 + const copy = COPY[other]; 80 + --- 81 + 82 + { 83 + !isApiReference && ( 84 + <div 85 + id="lang-suggest" 86 + class="lang-suggest" 87 + role="region" 88 + aria-label={copy.region} 89 + data-current={current} 90 + data-other={other} 91 + data-target={counterpart} 92 + data-default-mode={DEFAULT_MODE} 93 + hidden 94 + > 95 + <p lang={other}>{copy.text}</p> 96 + <a class="lang-suggest-go" href={counterpart} hreflang={other} lang={other}> 97 + {copy.action} 98 + </a> 99 + <button type="button" class="lang-suggest-close" lang={other} data-lang-dismiss> 100 + {copy.dismiss} 101 + </button> 102 + </div> 103 + ) 104 + } 105 + 106 + <script is:inline> 107 + (() => { 108 + var LANG_KEY = 'phonometry:lang'; 109 + var DISMISS_KEY = 'phonometry:lang-dismissed'; 110 + var MODE_KEY = 'phonometry:lang-mode'; 111 + var SESSION_KEY = 'phonometry:lang-redirected'; 112 + var BOTS = 113 + /bot|crawl|spider|slurp|mediapartners|facebookexternalhit|embedly|quora link preview|whatsapp|telegram|preview|scanner|lighthouse|pagespeed/i; 114 + 115 + function get(store, key) { 116 + try { 117 + return store.getItem(key); 118 + } catch (e) { 119 + return null; 120 + } 121 + } 122 + function set(store, key, value) { 123 + try { 124 + store.setItem(key, value); 125 + } catch (e) { 126 + /* private mode: the choice just does not survive the tab */ 127 + } 128 + } 129 + 130 + // Record the choice whenever the language picker is used, in either 131 + // header instance. The select's value is the localized pathname. 132 + document.addEventListener( 133 + 'change', 134 + function (event) { 135 + var el = event.target; 136 + if (!el || el.tagName !== 'SELECT') return; 137 + if (!el.closest('starlight-lang-select')) return; 138 + var value = String(el.value || ''); 139 + set(localStorage, LANG_KEY, /\/es(\/|$)/.test(value) ? 'es' : 'en'); 140 + // A fresh explicit choice re-arms the suggestion for the other 141 + // direction; it is the dismissal that has to be forgotten, not 142 + // the choice. 143 + try { 144 + localStorage.removeItem(DISMISS_KEY); 145 + } catch (e) {} 146 + }, 147 + true, 148 + ); 149 + 150 + var banner = document.getElementById('lang-suggest'); 151 + if (!banner) return; 152 + 153 + var current = banner.dataset.current; 154 + var other = banner.dataset.other; 155 + var target = banner.dataset.target; 156 + var mode = get(localStorage, MODE_KEY) || banner.dataset.defaultMode; 157 + 158 + var dismiss = banner.querySelector('[data-lang-dismiss]'); 159 + if (dismiss) { 160 + dismiss.addEventListener('click', function () { 161 + banner.hidden = true; 162 + set(localStorage, DISMISS_KEY, '1'); 163 + // Dismissing is itself a decision: the reader wants this page's 164 + // language, so record it and stop guessing. 165 + set(localStorage, LANG_KEY, current); 166 + }); 167 + } 168 + var go = banner.querySelector('.lang-suggest-go'); 169 + if (go) { 170 + go.addEventListener('click', function () { 171 + set(localStorage, LANG_KEY, other); 172 + }); 173 + } 174 + 175 + if (mode === 'off') return; 176 + if (BOTS.test(navigator.userAgent || '')) return; 177 + 178 + // An explicit ?lang= in the URL is a decision, not a hint. 179 + var explicit = new URLSearchParams(location.search).get('lang'); 180 + if (explicit === 'en' || explicit === 'es') { 181 + set(localStorage, LANG_KEY, explicit); 182 + if (explicit === current) return; 183 + } 184 + 185 + var stored = get(localStorage, LANG_KEY); 186 + var preferred = stored; 187 + if (!preferred) { 188 + var list = navigator.languages && navigator.languages.length 189 + ? navigator.languages 190 + : [navigator.language || '']; 191 + for (var i = 0; i < list.length; i++) { 192 + var base = String(list[i]).toLowerCase().split('-')[0]; 193 + if (base === 'es' || base === 'en') { 194 + preferred = base; 195 + break; 196 + } 197 + } 198 + } 199 + 200 + // No opinion, or the page is already in the preferred language. 201 + if (!preferred || preferred === current) return; 202 + 203 + if ( 204 + mode === 'redirect' && 205 + !stored && // never navigate away once a choice exists 206 + !get(sessionStorage, SESSION_KEY) && 207 + target && 208 + target !== location.pathname 209 + ) { 210 + set(sessionStorage, SESSION_KEY, '1'); 211 + location.replace(target + location.search + location.hash); 212 + return; 213 + } 214 + 215 + if (get(localStorage, DISMISS_KEY)) return; 216 + banner.hidden = false; 217 + // Publish the bar's height so anything else pinned to the bottom edge 218 + // (the prototype switcher) can sit clear of it at any width. 219 + var publish = function () { 220 + document.documentElement.style.setProperty( 221 + '--ph-lang-bar-height', 222 + (banner.hidden ? 0 : banner.offsetHeight) + 'px', 223 + ); 224 + }; 225 + publish(); 226 + if (typeof ResizeObserver === 'function') new ResizeObserver(publish).observe(banner); 227 + if (dismiss) dismiss.addEventListener('click', publish); 228 + })(); 229 + </script> 230 + 231 + <style> 232 + .lang-suggest { 233 + position: fixed; 234 + inset-block-end: 0; 235 + inset-inline: 0; 236 + /* Above the header and the sidebar pane (navbar is 10, skip link 20), 237 + so the bar is never clipped by the sidebar on desktop. */ 238 + z-index: 15; 239 + display: flex; 240 + flex-wrap: wrap; 241 + align-items: center; 242 + gap: 0.5rem 1rem; 243 + padding: 0.6rem clamp(0.75rem, 3vw, 2rem); 244 + background: var(--ph-surface-raised, var(--sl-color-bg-nav)); 245 + border-block-start: 1px solid var(--ph-border, var(--sl-color-hairline)); 246 + box-shadow: var(--sl-shadow-md); 247 + font-size: var(--sl-text-sm); 248 + } 249 + .lang-suggest[hidden] { 250 + display: none; 251 + } 252 + .lang-suggest p { 253 + margin: 0; 254 + color: var(--sl-color-gray-2); 255 + } 256 + .lang-suggest-go { 257 + color: var(--sl-color-text-accent); 258 + font-weight: 600; 259 + } 260 + .lang-suggest-close { 261 + margin-inline-start: auto; 262 + background: none; 263 + border: 1px solid var(--ph-border, var(--sl-color-hairline)); 264 + border-radius: var(--ph-radius, 4px); 265 + color: var(--sl-color-gray-3); 266 + padding: 0.25rem 0.6rem; 267 + font-size: var(--sl-text-xs); 268 + cursor: pointer; 269 + } 270 + .lang-suggest-close:hover { 271 + color: var(--sl-color-white); 272 + border-color: var(--ph-border-strong, var(--sl-color-gray-4)); 273 + } 274 + @media print { 275 + .lang-suggest { 276 + display: none; 277 + } 278 + } 279 + </style>
+243
site/src/components/PrototypeSwitcher.astro
··· 1 + --- 2 + /** 3 + * Experimental switcher for the two things this branch wants a decision on. 4 + * 5 + * Colour: three complete palettes live in src/styles/theme-directions.css, 6 + * keyed on a `data-accent` attribute on <html>. The attribute is stamped 7 + * before first paint by the inline script in Head.astro (so there is no 8 + * flash) and read back from localStorage under `phonometry:accent`; this 9 + * control only writes it. 10 + * 11 + * Language: the mode used by src/components/LangSuggest.astro, stored under 12 + * `phonometry:lang-mode`. Changing it reloads the page, because the decision 13 + * is taken once at load. 14 + * 15 + * Neither is meant to ship. Once a direction and a mode are picked, the 16 + * winning colour block becomes the unconditional `:root` rule, the mode 17 + * becomes the component's `DEFAULT_MODE`, and this component goes away. 18 + * 19 + * Rendered from Footer.astro so it exists on every page, splash included, and 20 + * positioned fixed. The buttons are real <button>s in labelled groups with 21 + * `aria-pressed`, so the control is reachable and announced; the panel starts 22 + * collapsed to stay out of the reading column. 23 + */ 24 + const DIRECTIONS = [ 25 + { id: 'instrument', label: 'Instrument' }, 26 + { id: 'blueprint', label: 'Blueprint' }, 27 + { id: 'graphite', label: 'Graphite' }, 28 + ] as const; 29 + 30 + const LANG_MODES = [ 31 + { id: 'banner', label: 'Banner' }, 32 + { id: 'redirect', label: 'Redirect' }, 33 + { id: 'off', label: 'Off' }, 34 + ] as const; 35 + --- 36 + 37 + <div class="accent-switcher" id="accent-switcher"> 38 + <button 39 + type="button" 40 + class="accent-toggle" 41 + aria-expanded="false" 42 + aria-controls="accent-options" 43 + data-accent-toggle 44 + > 45 + <span class="accent-toggle-dot" aria-hidden="true"></span> 46 + <span class="accent-toggle-label">Prototype</span> 47 + </button> 48 + <div class="accent-options" id="accent-options" hidden> 49 + <div class="accent-group" role="group" aria-label="Colour direction"> 50 + <span class="accent-group-label" aria-hidden="true">Colour</span> 51 + { 52 + DIRECTIONS.map((d) => ( 53 + <button type="button" class="accent-option" data-accent-set={d.id} aria-pressed="false"> 54 + <span class={`accent-swatch accent-swatch-${d.id}`} aria-hidden="true" /> 55 + {d.label} 56 + </button> 57 + )) 58 + } 59 + </div> 60 + <div class="accent-group" role="group" aria-label="Language detection mode"> 61 + <span class="accent-group-label" aria-hidden="true">Language</span> 62 + { 63 + LANG_MODES.map((m) => ( 64 + <button type="button" class="accent-option" data-lang-mode={m.id} aria-pressed="false"> 65 + {m.label} 66 + </button> 67 + )) 68 + } 69 + </div> 70 + </div> 71 + </div> 72 + 73 + <script is:inline> 74 + (() => { 75 + const root = document.documentElement; 76 + const KEY = 'phonometry:accent'; 77 + const panel = document.getElementById('accent-switcher'); 78 + if (!panel) return; 79 + const options = panel.querySelector('#accent-options'); 80 + const toggle = panel.querySelector('[data-accent-toggle]'); 81 + 82 + const LANG_MODE_KEY = 'phonometry:lang-mode'; 83 + const read = (key, fallback) => { 84 + try { 85 + return localStorage.getItem(key) || fallback; 86 + } catch { 87 + return fallback; 88 + } 89 + }; 90 + 91 + const sync = () => { 92 + const active = root.dataset.accent || 'instrument'; 93 + for (const btn of panel.querySelectorAll('[data-accent-set]')) { 94 + btn.setAttribute('aria-pressed', String(btn.dataset.accentSet === active)); 95 + } 96 + const banner = document.getElementById('lang-suggest'); 97 + const mode = read(LANG_MODE_KEY, banner?.dataset.defaultMode || 'banner'); 98 + for (const btn of panel.querySelectorAll('[data-lang-mode]')) { 99 + btn.setAttribute('aria-pressed', String(btn.dataset.langMode === mode)); 100 + } 101 + }; 102 + 103 + for (const btn of panel.querySelectorAll('[data-lang-mode]')) { 104 + btn.addEventListener('click', () => { 105 + try { 106 + localStorage.setItem(LANG_MODE_KEY, btn.dataset.langMode); 107 + // The suggestion is decided once at load, and the redirect 108 + // guard is per session, so both have to be reset to see the 109 + // new mode behave as it would on a first visit. 110 + localStorage.removeItem('phonometry:lang-dismissed'); 111 + sessionStorage.removeItem('phonometry:lang-redirected'); 112 + } catch { 113 + /* private mode */ 114 + } 115 + location.reload(); 116 + }); 117 + } 118 + 119 + for (const btn of panel.querySelectorAll('[data-accent-set]')) { 120 + btn.addEventListener('click', () => { 121 + root.dataset.accent = btn.dataset.accentSet; 122 + try { 123 + localStorage.setItem(KEY, btn.dataset.accentSet); 124 + } catch { 125 + /* private mode: the choice just does not survive the tab */ 126 + } 127 + sync(); 128 + }); 129 + } 130 + 131 + toggle?.addEventListener('click', () => { 132 + const open = toggle.getAttribute('aria-expanded') === 'true'; 133 + toggle.setAttribute('aria-expanded', String(!open)); 134 + if (options) options.hidden = open; 135 + }); 136 + 137 + sync(); 138 + })(); 139 + </script> 140 + 141 + <style> 142 + .accent-switcher { 143 + position: fixed; 144 + inset-block-end: 0.75rem; 145 + inset-inline-end: 0.75rem; 146 + z-index: 50; 147 + display: flex; 148 + flex-direction: column; 149 + align-items: flex-end; 150 + gap: 0.375rem; 151 + font-family: var(--__sl-font-mono); 152 + font-size: var(--sl-text-2xs); 153 + line-height: 1; 154 + } 155 + 156 + .accent-toggle, 157 + .accent-option { 158 + display: flex; 159 + align-items: center; 160 + gap: 0.5rem; 161 + padding: 0.4rem 0.6rem; 162 + border: 1px solid var(--ph-border); 163 + border-radius: var(--ph-radius, 4px); 164 + background: var(--ph-surface-raised, var(--sl-color-bg)); 165 + color: var(--sl-color-gray-2); 166 + cursor: pointer; 167 + text-transform: uppercase; 168 + letter-spacing: 0.06em; 169 + box-shadow: var(--sl-shadow-sm); 170 + } 171 + 172 + .accent-toggle:hover, 173 + .accent-option:hover { 174 + border-color: var(--ph-border-strong); 175 + color: var(--sl-color-white); 176 + } 177 + 178 + .accent-toggle-dot { 179 + width: 0.55rem; 180 + height: 0.55rem; 181 + border-radius: 50%; 182 + background: var(--ph-mark); 183 + } 184 + 185 + .accent-options { 186 + display: flex; 187 + flex-direction: column; 188 + align-items: stretch; 189 + gap: 0.6rem; 190 + } 191 + .accent-group { 192 + display: flex; 193 + flex-direction: column; 194 + align-items: stretch; 195 + gap: 0.25rem; 196 + } 197 + .accent-group-label { 198 + color: var(--sl-color-gray-3); 199 + text-transform: uppercase; 200 + letter-spacing: 0.1em; 201 + padding-inline-start: 0.1rem; 202 + } 203 + /* `display: flex` would otherwise beat the user-agent [hidden] rule. */ 204 + .accent-options[hidden] { 205 + display: none; 206 + } 207 + 208 + .accent-option[aria-pressed='true'] { 209 + border-color: var(--ph-mark); 210 + color: var(--sl-color-white); 211 + } 212 + 213 + .accent-swatch { 214 + width: 0.75rem; 215 + height: 0.75rem; 216 + border-radius: 2px; 217 + border: 1px solid var(--ph-border-strong); 218 + } 219 + /* Fixed swatch colours: each one previews its own direction, so they 220 + cannot follow the active palette's tokens. */ 221 + .accent-swatch-instrument { 222 + background: linear-gradient(135deg, #0a6f8c 50%, #35b8d8 50%); 223 + } 224 + .accent-swatch-blueprint { 225 + background: linear-gradient(135deg, #23479a 50%, #78a2f0 50%); 226 + } 227 + .accent-swatch-graphite { 228 + background: linear-gradient(135deg, #8a4b06 50%, #e0a33e 50%); 229 + } 230 + 231 + /* The language suggestion is a full-width bar pinned to the same edge and 232 + is rendered just before this component, so lift the panel clear of it 233 + while it is showing. */ 234 + :global(#lang-suggest:not([hidden])) ~ .accent-switcher { 235 + inset-block-end: calc(var(--ph-lang-bar-height, 3rem) + 0.75rem); 236 + } 237 + 238 + @media print { 239 + .accent-switcher { 240 + display: none; 241 + } 242 + } 243 + </style>
+355
site/src/data/home.ts
··· 1 + /** 2 + * Landing-page content, one object per locale. 3 + * 4 + * The two locales share this single shape, so a block cannot exist in one 5 + * language and quietly go missing in the other; index.mdx and es/index.mdx 6 + * import from here and pass the object down to the components in 7 + * src/components/home/. 8 + * 9 + * Every number in `stats` and every claim in `honest` is taken from the 10 + * repository, not written by hand from memory: 11 + * 427 / 53 / 278 docs/CONFORMANCE.md header line 12 + * 66 guides site/src/content/docs/guides/*.md* 13 + * 120 API pages site/src/content/docs/reference/api/** 14 + * 506 figures distinct basenames in .github/images (x2 themes x2 langs) 15 + * 32 PDF fiches .github/reports/*.pdf 16 + * 3.2.0 VERSION 17 + */ 18 + 19 + export interface Stat { 20 + value: string; 21 + label: string; 22 + href?: string; 23 + } 24 + 25 + export interface Area { 26 + name: string; 27 + href: string; 28 + standards: string[]; 29 + summary: string; 30 + } 31 + 32 + export interface Step { 33 + title: string; 34 + body: string; 35 + code?: string; 36 + href: string; 37 + linkText: string; 38 + } 39 + 40 + export interface HomeContent { 41 + /** Accessible name for the numbers row, which has no visible heading. */ 42 + statsLabel: string; 43 + stats: Stat[]; 44 + what: { title: string; body: string[] }; 45 + who: { title: string; items: string[] }; 46 + honest: { title: string; items: string[] }; 47 + proof: { 48 + title: string; 49 + lead: string; 50 + spectrum: { title: string; note: string; code: string; alt: string; caption: string }; 51 + fiche: { title: string; note: string; code: string; caption: string; description: string; linkTitle: string }; 52 + }; 53 + coverage: { title: string; lead: string; areas: Area[]; headers: [string, string] }; 54 + start: { title: string; lead: string; steps: Step[] }; 55 + } 56 + 57 + const SPECTRUM_CODE = `import numpy as np 58 + from phonometry import metrology 59 + 60 + fs = 48_000 61 + t = np.linspace(0, 1, fs, endpoint=False) 62 + signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) 63 + 64 + spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3)`; 65 + 66 + const FICHE_CODE = `from phonometry import building, ReportMetadata 67 + 68 + R = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6, 69 + 28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5] 70 + 71 + building.weighted_rating(R).report("Rw_fiche.pdf", metadata=meta)`; 72 + 73 + export const en: HomeContent = { 74 + statsLabel: 'The library in four numbers', 75 + stats: [ 76 + { value: '427 / 427', label: 'conformance checks passing', href: '/phonometry/reference/conformance/' }, 77 + { value: '278', label: 'standards referenced, across 53 domains' }, 78 + { value: '506', label: 'figures, each in light and dark, English and Spanish' }, 79 + { value: '32', label: 'normative PDF fiches rendered by .report()' }, 80 + ], 81 + what: { 82 + title: 'What this is', 83 + body: [ 84 + 'phonometry is a Python library for acoustic measurement, analysis and prediction. You give it a signal, a measured spectrum or a set of geometrical and material inputs; it gives you the quantity the standard defines, with the intermediate terms still visible.', 85 + 'Every result is a typed, frozen dataclass. It carries the inputs it was computed from, it draws its own figure with a one-line <code>.plot()</code> in English or Spanish, and where a standard defines a reporting format it renders a one-page PDF fiche with <code>.report()</code>.', 86 + 'It is written and maintained by one person, published under the MIT licence on PyPI, archived with a DOI on Zenodo, and currently at version 3.2.0. It needs Python 3.13 with NumPy and SciPy; matplotlib, numba and reportlab are optional extras.', 87 + ], 88 + }, 89 + who: { 90 + title: 'Who it is for', 91 + items: [ 92 + 'Consultants and test laboratories who need the number a standard defines and the terms behind it, in a form that can go into a report.', 93 + 'Researchers who need a readable, citable reference implementation rather than a black box, and who need to see which clause each formula came from.', 94 + 'Audio, firmware and product engineers who need class 1 weighting and filters, distortion, loudness and speech metrics inside their own test rigs.', 95 + 'Students and teachers, since every guide states the standard, the formula and the assumptions before the code.', 96 + ], 97 + }, 98 + honest: { 99 + title: 'What it is not', 100 + items: [ 101 + 'Not a certified instrument. The conformance report is the library checking its own output against values published in the standards, not an accredited third-party calibration. Legal measurements still need type-approved hardware.', 102 + 'Not a data-acquisition system. It processes arrays and files you supply; it does not talk to sound cards, microphones or analysers.', 103 + 'Not a complete implementation of every standard it cites. Each guide states which clauses, methods and annexes are covered, and which are not.', 104 + ], 105 + }, 106 + proof: { 107 + title: 'What it looks like in use', 108 + lead: 'Two things the library does that are hard to claim and easy to show: the result of an analysis draws itself, and where a standard prescribes a reporting layout, the same result renders that layout as a PDF.', 109 + spectrum: { 110 + title: 'An analysis, and the figure it draws', 111 + note: 'One-third-octave band levels of a two-tone signal, per IEC 61260-1 band edges, with the raw power spectral density behind them. The figure below is the one committed in the documentation, not a mock-up.', 112 + code: SPECTRUM_CODE, 113 + alt: 'One-third-octave spectrum of a two-tone signal, with the raw power spectral density in the background', 114 + caption: 'From the Getting Started guide: 33 bands from 12.6 Hz to 20 kHz, peaking at 100 Hz and 1 kHz.', 115 + }, 116 + fiche: { 117 + title: 'A rating, and the fiche it renders', 118 + note: 'The weighted airborne rating of ISO 717-1 from a measured 16-band spectrum, rendered in the layout an accredited report uses: metadata header, band table, measured curve against the shifted reference, the boxed single-number result and the verdict against the requirement.', 119 + code: FICHE_CODE, 120 + caption: 'Airborne rating fiche (WeightedRatingResult.report), Rw (C; Ctr).', 121 + linkTitle: 'Airborne ISO 717-1 example report (PDF)', 122 + description: 123 + 'One-page airborne sound insulation fiche: a metadata header, the one-third-octave R table beside the measured-versus-shifted-reference plot, the boxed Rw (C; Ctr) single-number result and a PASS verdict against the requirement.', 124 + }, 125 + }, 126 + coverage: { 127 + title: 'What it covers', 128 + lead: 'Nine areas, 66 guides, 120 API reference pages. Each row links to the area overview; the designations are the standards actually implemented there, not a reading list.', 129 + headers: ['Area', 'Standards implemented'], 130 + areas: [ 131 + { 132 + name: 'Core signal analysis', 133 + href: '/phonometry/guides/sections/core-signal-analysis/', 134 + summary: 'Filter banks, weighting, levels, spectra, calibration and uncertainty.', 135 + standards: ['IEC 61260-1', 'ANSI S1.11', 'IEC 61672-1', 'ISO 7196', 'IEC 61252', 'ISO 1996-1', 'IEC 60942', 'ISO 18233', 'GUM'], 136 + }, 137 + { 138 + name: 'Hearing and perception', 139 + href: '/phonometry/guides/sections/hearing-perception/', 140 + summary: 'Loudness, sound quality, speech intelligibility, hearing and exposure.', 141 + standards: ['ISO 532-1/-2/-3', 'ECMA-418-1/-2', 'ISO 226', 'DIN 45692', 'IEC 60268-16', 'ANSI S3.5', 'ISO 7029', 'ISO 1999', 'ISO 9612'], 142 + }, 143 + { 144 + name: 'Rooms and buildings', 145 + href: '/phonometry/guides/sections/rooms-buildings/', 146 + summary: 'Room parameters, background noise, field and laboratory insulation, prediction.', 147 + standards: ['ISO 3382-1/-2/-3', 'ISO 16283-1/-2/-3', 'ISO 10140', 'ISO 717-1/-2', 'EN 12354-1…-6', 'ISO 12999-1', 'ISO 10052', 'ANSI/ASA S12.2'], 148 + }, 149 + { 150 + name: 'Materials and surfaces', 151 + href: '/phonometry/guides/sections/materials-surfaces/', 152 + summary: 'Absorption, airflow resistance, impedance tube, porous models, scattering.', 153 + standards: ['ISO 354', 'ISO 11654', 'ISO 10534-2', 'ISO 9053', 'ISO 17497-1/-2', 'ISO 13472', 'EN 29052'], 154 + }, 155 + { 156 + name: 'Vibration and structure-borne sound', 157 + href: '/phonometry/guides/sections/vibration/', 158 + summary: 'Mobility and FRFs, isolators, radiated power, junctions, human vibration.', 159 + standards: ['ISO 7626', 'ISO 10846', 'ISO/TS 7849', 'EN 15657', 'EN 12354-5', 'ISO 2631-1/-5', 'ISO 5349', 'ISO 8041'], 160 + }, 161 + { 162 + name: 'Environment and transport', 163 + href: '/phonometry/guides/sections/environment-transport/', 164 + summary: 'Outdoor propagation, barriers, refraction, aircraft, rotorcraft and wind turbines.', 165 + standards: ['ISO 9613-1/-2', 'ISO 1996-1/-2', 'ISO/PAS 1996-3', 'NT ACOU 112', 'ICAO Annex 16', 'IEC 61265', 'SAE ARP 5534', 'ECAC Doc 29/32', 'IEC 61400-11'], 166 + }, 167 + { 168 + name: 'Underwater acoustics', 169 + href: '/phonometry/guides/sections/underwater/', 170 + summary: 'Levels re 1 µPa, ship radiated noise, pile driving, ambient noise, transmission loss.', 171 + standards: ['ISO 18405', 'ISO 17208-1/-2', 'ISO 18406', 'JOMOPANS-ECHO'], 172 + }, 173 + { 174 + name: 'Sources and devices', 175 + href: '/phonometry/guides/sections/sources-devices/', 176 + summary: 'Sound power, intensity, emission declarations, electroacoustics, programme loudness.', 177 + standards: ['ISO 3741', 'ISO 3744/3746', 'ISO 3745', 'ISO 9614-1/-2/-3', 'IEC 61043', 'ISO 4871', 'IEC 60268-3/-4/-5', 'ITU-R BS.1770-5', 'EBU R 128'], 178 + }, 179 + { 180 + name: 'Wave simulation', 181 + href: '/phonometry/guides/sections/simulation/', 182 + summary: 'A deterministic 2D FDTD solver, validated against analytic oracles rather than a standard.', 183 + standards: ['no governing standard'], 184 + }, 185 + ], 186 + }, 187 + start: { 188 + title: 'Starting from zero', 189 + lead: 'Three steps, in order. The first two take a few minutes; the third is the part you keep coming back to.', 190 + steps: [ 191 + { 192 + title: 'Install it', 193 + body: 'Python 3.13 or newer. The base install pulls in NumPy and SciPy; the full extra adds plotting, the faster impulse ballistics and PDF fiche rendering.', 194 + code: 'pip install phonometry[full]', 195 + href: '/phonometry/getting-started/', 196 + linkText: 'Installation options', 197 + }, 198 + { 199 + title: 'Run one analysis end to end', 200 + body: 'Getting Started walks the whole processing chain once, on a synthetic signal and then on a WAV file: calibration, frequency weighting, the filter bank, time weighting and the levels that come out.', 201 + href: '/phonometry/getting-started/', 202 + linkText: 'Getting Started', 203 + }, 204 + { 205 + title: 'Go to your own domain', 206 + body: 'Each guide opens with the standard it implements, the quantities it defines and the assumptions, then the code, then the figure. When you need the exact signature, the API reference has one page per module.', 207 + href: '/phonometry/reference/api/', 208 + linkText: 'API reference', 209 + }, 210 + ], 211 + }, 212 + }; 213 + 214 + export const es: HomeContent = { 215 + statsLabel: 'La biblioteca en cuatro cifras', 216 + stats: [ 217 + { value: '427 / 427', label: 'comprobaciones de conformidad superadas', href: '/phonometry/es/reference/conformance/' }, 218 + { value: '278', label: 'normas implementadas, en 53 dominios' }, 219 + { value: '506', label: 'figuras, cada una en claro y oscuro, en inglés y español' }, 220 + { value: '32', label: 'fichas PDF normativas que genera .report()' }, 221 + ], 222 + what: { 223 + title: 'Qué es', 224 + body: [ 225 + 'phonometry es una biblioteca de Python para medición, análisis y predicción acústica. Le das una señal, un espectro medido o un conjunto de datos geométricos y de materiales, y te devuelve la magnitud que define la norma, con los términos intermedios a la vista.', 226 + 'Cada resultado es un dataclass tipado e inmutable. Conserva los datos de entrada con los que se calculó, dibuja su propia figura con un <code>.plot()</code> de una línea en inglés o en español y, cuando una norma define un formato de informe, genera una ficha PDF de una página con <code>.report()</code>.', 227 + 'La escribe y la mantiene una sola persona, se publica con licencia MIT en PyPI, se archiva con DOI en Zenodo y va por la versión 3.2.0. Necesita Python 3.13 con NumPy y SciPy; matplotlib, numba y reportlab son extras opcionales.', 228 + ], 229 + }, 230 + who: { 231 + title: 'Para quién es', 232 + items: [ 233 + 'Consultores y laboratorios de ensayo que necesitan el número que define la norma y los términos que hay detrás, en una forma que se pueda llevar a un informe.', 234 + 'Investigadores que necesitan una implementación de referencia legible y citable en lugar de una caja negra, y ver de qué apartado sale cada fórmula.', 235 + 'Ingenieros de audio, firmware y producto que necesitan filtros y ponderaciones de clase 1, distorsión, sonoridad y métricas de habla dentro de sus propios bancos de ensayo.', 236 + 'Estudiantes y docentes, porque cada guía enuncia la norma, la fórmula y las hipótesis antes del código.', 237 + ], 238 + }, 239 + honest: { 240 + title: 'Qué no es', 241 + items: [ 242 + 'No es un instrumento certificado. El informe de conformidad es la biblioteca comprobando su propia salida frente a valores publicados en las normas, no una calibración acreditada por un tercero. Una medición con validez legal sigue necesitando equipo con aprobación de modelo.', 243 + 'No es un sistema de adquisición de datos. Procesa los arrays y los archivos que le pases; no habla con tarjetas de sonido, micrófonos ni analizadores.', 244 + 'No implementa por completo todas las normas que cita. Cada guía indica qué apartados, métodos y anexos cubre y cuáles no.', 245 + ], 246 + }, 247 + proof: { 248 + title: 'Cómo se ve en uso', 249 + lead: 'Dos cosas difíciles de afirmar y fáciles de enseñar: el resultado de un análisis se dibuja solo y, cuando una norma prescribe un formato de informe, ese mismo resultado lo genera en PDF.', 250 + spectrum: { 251 + title: 'Un análisis y la figura que dibuja', 252 + note: 'Niveles por bandas de tercio de octava de una señal de dos tonos, con los límites de banda de IEC 61260-1 y la densidad espectral de potencia bruta al fondo. La figura de abajo es la que está publicada en la documentación, no una maqueta.', 253 + code: SPECTRUM_CODE, 254 + alt: 'Espectro en tercios de octava de una señal de dos tonos, con la densidad espectral de potencia bruta al fondo', 255 + caption: 'De la guía de introducción: 33 bandas de 12,6 Hz a 20 kHz, con máximos en 100 Hz y 1 kHz.', 256 + }, 257 + fiche: { 258 + title: 'Un índice global y la ficha que genera', 259 + note: 'El índice global de aislamiento a ruido aéreo de ISO 717-1 a partir de un espectro medido de 16 bandas, con el formato que usa un informe acreditado: cabecera de metadatos, tabla por bandas, curva medida frente a la curva de referencia desplazada, el resultado de un solo número enmarcado y el veredicto frente al requisito.', 260 + code: FICHE_CODE, 261 + caption: 'Ficha del índice global a ruido aéreo (WeightedRatingResult.report), Rw (C; Ctr).', 262 + linkTitle: 'Informe de ejemplo ISO 717-1 a ruido aéreo (PDF)', 263 + description: 264 + 'Ficha de aislamiento a ruido aéreo de una página: cabecera de metadatos, tabla de R en tercios de octava junto al gráfico de la curva medida frente a la de referencia desplazada, el resultado Rw (C; Ctr) enmarcado y el veredicto APTO frente al requisito.', 265 + }, 266 + }, 267 + coverage: { 268 + title: 'Qué abarca', 269 + lead: 'Nueve áreas, 66 guías y 120 páginas de referencia de la API. Cada fila enlaza al índice del área; las designaciones son las normas realmente implementadas ahí, no una lista de lecturas.', 270 + headers: ['Área', 'Normas implementadas'], 271 + areas: [ 272 + { 273 + name: 'Análisis de señal', 274 + href: '/phonometry/es/guides/sections/core-signal-analysis/', 275 + summary: 'Bancos de filtros, ponderaciones, niveles, espectros, calibración e incertidumbre.', 276 + standards: ['IEC 61260-1', 'ANSI S1.11', 'IEC 61672-1', 'ISO 7196', 'IEC 61252', 'ISO 1996-1', 'IEC 60942', 'ISO 18233', 'GUM'], 277 + }, 278 + { 279 + name: 'Audición y percepción', 280 + href: '/phonometry/es/guides/sections/hearing-perception/', 281 + summary: 'Sonoridad, calidad sonora, inteligibilidad del habla, audición y exposición.', 282 + standards: ['ISO 532-1/-2/-3', 'ECMA-418-1/-2', 'ISO 226', 'DIN 45692', 'IEC 60268-16', 'ANSI S3.5', 'ISO 7029', 'ISO 1999', 'ISO 9612'], 283 + }, 284 + { 285 + name: 'Salas y edificación', 286 + href: '/phonometry/es/guides/sections/rooms-buildings/', 287 + summary: 'Parámetros de sala, ruido de fondo, aislamiento en campo y laboratorio, predicción.', 288 + standards: ['ISO 3382-1/-2/-3', 'ISO 16283-1/-2/-3', 'ISO 10140', 'ISO 717-1/-2', 'EN 12354-1…-6', 'ISO 12999-1', 'ISO 10052', 'ANSI/ASA S12.2'], 289 + }, 290 + { 291 + name: 'Materiales y superficies', 292 + href: '/phonometry/es/guides/sections/materials-surfaces/', 293 + summary: 'Absorción, resistencia al flujo de aire, tubo de impedancia, modelos porosos, dispersión.', 294 + standards: ['ISO 354', 'ISO 11654', 'ISO 10534-2', 'ISO 9053', 'ISO 17497-1/-2', 'ISO 13472', 'EN 29052'], 295 + }, 296 + { 297 + name: 'Vibración y ruido estructural', 298 + href: '/phonometry/es/guides/sections/vibration/', 299 + summary: 'Movilidad y FRF, aisladores, potencia radiada, uniones, vibración en humanos.', 300 + standards: ['ISO 7626', 'ISO 10846', 'ISO/TS 7849', 'EN 15657', 'EN 12354-5', 'ISO 2631-1/-5', 'ISO 5349', 'ISO 8041'], 301 + }, 302 + { 303 + name: 'Medio ambiente y transporte', 304 + href: '/phonometry/es/guides/sections/environment-transport/', 305 + summary: 'Propagación en exteriores, barreras, refracción, aeronaves, helicópteros y aerogeneradores.', 306 + standards: ['ISO 9613-1/-2', 'ISO 1996-1/-2', 'ISO/PAS 1996-3', 'NT ACOU 112', 'Anexo 16 OACI', 'IEC 61265', 'SAE ARP 5534', 'Doc 29/32 CEAC', 'IEC 61400-11'], 307 + }, 308 + { 309 + name: 'Acústica submarina', 310 + href: '/phonometry/es/guides/sections/underwater/', 311 + summary: 'Niveles re 1 µPa, ruido radiado por buques, hincado de pilotes, ruido ambiente, pérdidas por transmisión.', 312 + standards: ['ISO 18405', 'ISO 17208-1/-2', 'ISO 18406', 'JOMOPANS-ECHO'], 313 + }, 314 + { 315 + name: 'Fuentes y dispositivos', 316 + href: '/phonometry/es/guides/sections/sources-devices/', 317 + summary: 'Potencia acústica, intensidad, declaraciones de emisión, electroacústica, sonoridad de programa.', 318 + standards: ['ISO 3741', 'ISO 3744/3746', 'ISO 3745', 'ISO 9614-1/-2/-3', 'IEC 61043', 'ISO 4871', 'IEC 60268-3/-4/-5', 'ITU-R BS.1770-5', 'EBU R 128'], 319 + }, 320 + { 321 + name: 'Simulación de ondas', 322 + href: '/phonometry/es/guides/sections/simulation/', 323 + summary: 'Un solver FDTD 2D determinista, validado frente a oráculos analíticos y no frente a una norma.', 324 + standards: ['sin norma aplicable'], 325 + }, 326 + ], 327 + }, 328 + start: { 329 + title: 'Empezar desde cero', 330 + lead: 'Tres pasos, en orden. Los dos primeros son cuestión de minutos; al tercero se vuelve una y otra vez.', 331 + steps: [ 332 + { 333 + title: 'Instálala', 334 + body: 'Python 3.13 o posterior. La instalación básica trae NumPy y SciPy; el extra completo añade las figuras, la ponderación temporal de impulso acelerada y la generación de fichas PDF.', 335 + code: 'pip install phonometry[full]', 336 + href: '/phonometry/es/getting-started/', 337 + linkText: 'Opciones de instalación', 338 + }, 339 + { 340 + title: 'Haz un análisis completo', 341 + body: 'La guía de introducción recorre una vez toda la cadena de proceso, primero con una señal sintética y después con un archivo WAV: calibración, ponderación frecuencial, banco de filtros, ponderación temporal y los niveles que salen.', 342 + href: '/phonometry/es/getting-started/', 343 + linkText: 'Introducción', 344 + }, 345 + { 346 + title: 'Ve a tu dominio', 347 + body: 'Cada guía empieza por la norma que implementa, las magnitudes que define y las hipótesis, y sigue con el código y la figura. Cuando necesites la firma exacta, la referencia de la API tiene una página por módulo.', 348 + href: '/phonometry/reference/api/', 349 + linkText: 'Referencia de la API', 350 + }, 351 + ], 352 + }, 353 + }; 354 + 355 + export const home = { en, es };
+54
site/src/styles/home.css
··· 1 + /* Landing-page layout. 2 + The splash page hides the sidebar on desktop (splash-menu.css), so the 3 + reading column can be wider than the 45rem the guides use: the landing has 4 + a four-column readout, a two-column table and a three-column start path, 5 + none of which work at guide width. Scoped to [data-has-hero] so no other 6 + page is affected. */ 7 + :root[data-has-hero] { 8 + --sl-content-width: 62rem; 9 + } 10 + 11 + /* Starlight reserves the sidebar gutter on every page that reports a sidebar, 12 + and the route middleware reports one on splash pages so the mobile menu 13 + button exists. With the sidebar pane hidden on desktop that gutter is dead 14 + space, and at 62rem it pushed the whole landing off-centre. */ 15 + @media (min-width: 50rem) { 16 + :root[data-has-hero] { 17 + --sl-content-inline-start: 0rem; 18 + } 19 + 20 + /* The hero grid is 7fr 4fr even when there is no hero image, which caps the 21 + copy at 62 % of a column that is now wide. There is no image here. */ 22 + [data-has-hero] .hero { 23 + grid-template-columns: 100%; 24 + } 25 + } 26 + 27 + /* Starlight's prose flow gives every following sibling inside 28 + .sl-markdown-content a 1rem top margin. The landing page is a layout, not 29 + prose, and sets its own spacing: without this the four readout cells drift 30 + apart and the grid's hairline shows through. Zero specificity, so every 31 + rule in Home.astro still wins, and unlayered, so it beats the layered 32 + Starlight default it is switching off. */ 33 + :where(.sl-markdown-content .home *) { 34 + margin-top: 0; 35 + } 36 + 37 + /* The hero itself: keep the measure of the tagline readable even though the 38 + column around it is now wide, and let the wordmark carry the page. */ 39 + [data-has-hero] .hero > .stack { 40 + gap: 1.25rem; 41 + } 42 + [data-has-hero] .hero h1 { 43 + letter-spacing: -0.02em; 44 + } 45 + [data-has-hero] .hero .tagline { 46 + max-width: 58ch; 47 + color: var(--sl-color-gray-2); 48 + } 49 + 50 + /* Starlight gives splash actions a pill; square them off to match the rest of 51 + the chrome, which is hairlines and 4px radii rather than rounded buttons. */ 52 + [data-has-hero] .sl-link-button { 53 + border-radius: var(--ph-radius, 4px); 54 + }
+481
site/src/components/home/Home.astro
··· 1 + --- 2 + /** 3 + * Landing-page body. 4 + * 5 + * Replaces the ten dense description cards the splash page used to carry with 6 + * six blocks that each answer one question a first-time visitor actually has: 7 + * how big is this, what is it, who is it for, what is it not, what does it 8 + * look like in use, what does it cover, and where do I start. 9 + * 10 + * All the text comes from `src/data/home.ts`, one object per locale, so the 11 + * English and Spanish landings cannot drift apart structurally. The figure and 12 + * the PDF fiche are the ones already committed for the guides; nothing here is 13 + * a mock-up. 14 + */ 15 + import { Code } from '@astrojs/starlight/components'; 16 + import ThemeImage from '../ThemeImage.astro'; 17 + import ReportPreview from '../ReportPreview.astro'; 18 + import type { HomeContent } from '../../data/home'; 19 + 20 + interface Props { 21 + content: HomeContent; 22 + lang: 'en' | 'es'; 23 + } 24 + 25 + const { content, lang } = Astro.props; 26 + const IMAGES = 'https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images'; 27 + // The Spanish build points at the localized figure variant, exactly as the 28 + // guides do; ThemeImage derives the dark file from whichever it is given. 29 + const spectrum = lang === 'es' 30 + ? `${IMAGES}/signal_response_fraction_3_es.svg` 31 + : `${IMAGES}/signal_response_fraction_3.svg`; 32 + --- 33 + 34 + <div class="home"> 35 + <section class="readout" aria-label={content.statsLabel}> 36 + { 37 + content.stats.map((s) => ( 38 + <div class="readout-cell"> 39 + <span class="readout-value">{s.value}</span> 40 + <span class="readout-label"> 41 + {s.href ? <a href={s.href}>{s.label}</a> : s.label} 42 + </span> 43 + </div> 44 + )) 45 + } 46 + </section> 47 + 48 + <section class="block"> 49 + <h2>{content.what.title}</h2> 50 + <div class="prose"> 51 + {content.what.body.map((p) => <p set:html={p} />)} 52 + </div> 53 + 54 + <div class="two-up"> 55 + <div class="panel"> 56 + <h3>{content.who.title}</h3> 57 + <ul class="tick"> 58 + {content.who.items.map((i) => <li>{i}</li>)} 59 + </ul> 60 + </div> 61 + <div class="panel panel-quiet"> 62 + <h3>{content.honest.title}</h3> 63 + <ul class="tick tick-minus"> 64 + {content.honest.items.map((i) => <li>{i}</li>)} 65 + </ul> 66 + </div> 67 + </div> 68 + </section> 69 + 70 + <section class="block"> 71 + <h2>{content.proof.title}</h2> 72 + <p class="lead">{content.proof.lead}</p> 73 + 74 + <div class="demo"> 75 + <h3>{content.proof.spectrum.title}</h3> 76 + <p class="note">{content.proof.spectrum.note}</p> 77 + <Code code={content.proof.spectrum.code} lang="python" /> 78 + <figure class="demo-figure"> 79 + <ThemeImage 80 + src={spectrum} 81 + alt={content.proof.spectrum.alt} 82 + width="100%" 83 + loading="eager" 84 + /> 85 + <figcaption>{content.proof.spectrum.caption}</figcaption> 86 + </figure> 87 + </div> 88 + 89 + <div class="demo demo-split"> 90 + <div> 91 + <h3>{content.proof.fiche.title}</h3> 92 + <p class="note">{content.proof.fiche.note}</p> 93 + <Code code={content.proof.fiche.code} lang="python" /> 94 + </div> 95 + <div class="demo-fiche"> 96 + <ReportPreview 97 + name="iso717_airborne_example" 98 + title={content.proof.fiche.linkTitle} 99 + description={content.proof.fiche.description} 100 + displayWidth="100%" 101 + caption={content.proof.fiche.caption} 102 + id="home-iso717" 103 + /> 104 + </div> 105 + </div> 106 + </section> 107 + 108 + <section class="block"> 109 + <h2>{content.coverage.title}</h2> 110 + <p class="lead">{content.coverage.lead}</p> 111 + <table class="coverage"> 112 + <thead> 113 + <tr> 114 + <th scope="col">{content.coverage.headers[0]}</th> 115 + <th scope="col">{content.coverage.headers[1]}</th> 116 + </tr> 117 + </thead> 118 + <tbody> 119 + { 120 + content.coverage.areas.map((a) => ( 121 + <tr> 122 + <th scope="row"> 123 + <a href={a.href}>{a.name}</a> 124 + <span class="area-summary">{a.summary}</span> 125 + </th> 126 + <td> 127 + <span class="chips"> 128 + {a.standards.map((s) => ( 129 + <span class="chip">{s}</span> 130 + ))} 131 + </span> 132 + </td> 133 + </tr> 134 + )) 135 + } 136 + </tbody> 137 + </table> 138 + </section> 139 + 140 + <section class="block"> 141 + <h2>{content.start.title}</h2> 142 + <p class="lead">{content.start.lead}</p> 143 + <ol class="steps"> 144 + { 145 + content.start.steps.map((s, i) => ( 146 + <li> 147 + <span class="step-n" aria-hidden="true"> 148 + {String(i + 1).padStart(2, '0')} 149 + </span> 150 + <h3>{s.title}</h3> 151 + <p>{s.body}</p> 152 + {s.code && <Code code={s.code} lang="bash" frame="none" />} 153 + <a class="step-link" href={s.href}> 154 + {s.linkText} 155 + </a> 156 + </li> 157 + )) 158 + } 159 + </ol> 160 + </section> 161 + </div> 162 + 163 + <style> 164 + .home { 165 + --home-gap: clamp(2.5rem, 6vw, 4rem); 166 + display: flex; 167 + flex-direction: column; 168 + gap: var(--home-gap); 169 + margin-top: 1rem; 170 + } 171 + 172 + /* Sections are numbered and ruled, like the clauses of the documents this 173 + library implements. The counter is decorative and hidden from the 174 + accessibility tree; the heading text carries the meaning. */ 175 + .home { 176 + counter-reset: home-block; 177 + } 178 + .block { 179 + counter-increment: home-block; 180 + border-block-start: 1px solid var(--ph-border); 181 + padding-block-start: 1.4rem; 182 + position: relative; 183 + } 184 + .block::before { 185 + content: counter(home-block, decimal-leading-zero); 186 + position: absolute; 187 + inset-block-start: 1.5rem; 188 + inset-inline-start: 0; 189 + font-family: var(--__sl-font-mono); 190 + font-size: var(--sl-text-2xs); 191 + letter-spacing: 0.12em; 192 + color: var(--ph-mark); 193 + } 194 + @media (min-width: 62rem) { 195 + .block { 196 + padding-inline-start: 3.5rem; 197 + } 198 + } 199 + .block > h2 { 200 + font-size: var(--sl-text-2xl); 201 + line-height: 1.2; 202 + margin-bottom: 0.5rem; 203 + letter-spacing: -0.01em; 204 + } 205 + @media (min-width: 50rem) { 206 + .block > h2 { 207 + font-size: var(--sl-text-3xl); 208 + } 209 + } 210 + .block h3 { 211 + font-size: var(--sl-text-lg); 212 + margin-bottom: 0.35rem; 213 + } 214 + .lead { 215 + color: var(--sl-color-gray-3); 216 + max-width: 62ch; 217 + margin-bottom: 1.25rem; 218 + } 219 + 220 + /* ---- Instrument readout: the numbers, stated once, up top ---- */ 221 + .readout { 222 + display: grid; 223 + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); 224 + gap: 1px; 225 + background: var(--ph-border); 226 + border: 1px solid var(--ph-border); 227 + border-radius: var(--ph-radius, 4px); 228 + overflow: hidden; 229 + } 230 + .readout-cell { 231 + background: var(--ph-surface); 232 + padding: 1rem 1.1rem; 233 + display: flex; 234 + flex-direction: column; 235 + gap: 0.3rem; 236 + } 237 + .readout-value { 238 + font-family: var(--__sl-font-mono); 239 + font-size: var(--sl-text-2xl); 240 + font-variant-numeric: tabular-nums; 241 + color: var(--ph-accent-ink); 242 + line-height: 1.1; 243 + } 244 + .readout-label { 245 + font-size: var(--sl-text-xs); 246 + color: var(--sl-color-gray-3); 247 + line-height: 1.4; 248 + } 249 + 250 + /* ---- Prose ---- */ 251 + .prose { 252 + max-width: 68ch; 253 + } 254 + .prose p { 255 + margin-bottom: 0.9rem; 256 + } 257 + .prose :global(code) { 258 + font-size: var(--sl-text-code-sm); 259 + background: var(--sl-color-bg-inline-code); 260 + padding: 0.1em 0.35em; 261 + border-radius: 3px; 262 + } 263 + 264 + /* ---- Who it is for / what it is not ---- */ 265 + .two-up { 266 + display: grid; 267 + gap: 1.25rem; 268 + margin-top: 1.75rem; 269 + } 270 + @media (min-width: 55rem) { 271 + .two-up { 272 + grid-template-columns: 1fr 1fr; 273 + align-items: start; 274 + } 275 + } 276 + .panel { 277 + border: 1px solid var(--ph-border); 278 + border-radius: var(--ph-radius, 4px); 279 + background: var(--ph-surface); 280 + padding: 1.25rem 1.4rem; 281 + } 282 + .panel > h3 { 283 + font-size: var(--sl-text-lg); 284 + margin-bottom: 0.75rem; 285 + } 286 + .panel-quiet { 287 + background: transparent; 288 + border-style: dashed; 289 + } 290 + ul.tick { 291 + list-style: none; 292 + padding: 0; 293 + margin: 0; 294 + display: flex; 295 + flex-direction: column; 296 + gap: 0.7rem; 297 + } 298 + ul.tick li { 299 + position: relative; 300 + padding-inline-start: 1.4rem; 301 + font-size: var(--sl-text-sm); 302 + line-height: 1.6; 303 + } 304 + ul.tick li::before { 305 + content: '+'; 306 + position: absolute; 307 + inset-inline-start: 0; 308 + font-family: var(--__sl-font-mono); 309 + color: var(--ph-mark); 310 + } 311 + ul.tick-minus li::before { 312 + content: '-'; 313 + color: var(--sl-color-gray-4); 314 + } 315 + 316 + /* ---- Shows rather than tells ---- */ 317 + .demo { 318 + border: 1px solid var(--ph-border); 319 + border-radius: var(--ph-radius, 4px); 320 + padding: 1.25rem 1.4rem 1.4rem; 321 + margin-bottom: 1.25rem; 322 + background: var(--ph-surface); 323 + } 324 + .demo:last-child { 325 + margin-bottom: 0; 326 + } 327 + .note { 328 + font-size: var(--sl-text-sm); 329 + color: var(--sl-color-gray-3); 330 + max-width: 68ch; 331 + margin-bottom: 1rem; 332 + } 333 + .demo-figure { 334 + margin: 1.1rem 0 0; 335 + } 336 + .demo-figure img { 337 + width: 100%; 338 + height: auto; 339 + display: block; 340 + } 341 + .demo-figure figcaption, 342 + .demo :global(figcaption) { 343 + font-size: var(--sl-text-xs); 344 + color: var(--sl-color-gray-3); 345 + margin-top: 0.5rem; 346 + } 347 + .demo-split { 348 + display: grid; 349 + gap: 1.5rem; 350 + } 351 + /* Grid and flex items default to min-width: auto, so a code block's 352 + intrinsic width would widen the column and scroll the whole document 353 + sideways on a phone. Let the columns shrink and the code scroll. */ 354 + .demo-split > *, 355 + .two-up > *, 356 + ol.steps > li, 357 + .readout-cell { 358 + min-width: 0; 359 + } 360 + @media (min-width: 55rem) { 361 + .demo-split { 362 + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); 363 + align-items: start; 364 + } 365 + } 366 + .demo-fiche :global(.report-figure) { 367 + margin: 0; 368 + } 369 + 370 + /* ---- Coverage table ---- */ 371 + table.coverage { 372 + width: 100%; 373 + border-collapse: collapse; 374 + font-size: var(--sl-text-sm); 375 + } 376 + table.coverage th, 377 + table.coverage td { 378 + text-align: start; 379 + vertical-align: top; 380 + padding: 0.7rem 0.75rem; 381 + border-block-end: 1px solid var(--ph-border); 382 + } 383 + table.coverage thead th { 384 + font-size: var(--sl-text-2xs); 385 + text-transform: uppercase; 386 + letter-spacing: 0.08em; 387 + color: var(--sl-color-gray-3); 388 + font-weight: 600; 389 + border-block-end: 1px solid var(--ph-border-strong); 390 + } 391 + table.coverage tbody th { 392 + font-weight: 600; 393 + width: 40%; 394 + padding-inline-start: 0; 395 + } 396 + .area-summary { 397 + display: block; 398 + font-weight: 400; 399 + font-size: var(--sl-text-xs); 400 + color: var(--sl-color-gray-3); 401 + margin-top: 0.2rem; 402 + max-width: 34ch; 403 + } 404 + .chips { 405 + display: flex; 406 + flex-wrap: wrap; 407 + gap: 0.3rem; 408 + } 409 + .chip { 410 + font-family: var(--__sl-font-mono); 411 + font-size: var(--sl-text-2xs); 412 + line-height: 1.6; 413 + padding: 0.1rem 0.4rem; 414 + border: 1px solid var(--ph-border); 415 + border-radius: 3px; 416 + background: var(--ph-accent-soft); 417 + color: var(--ph-accent-ink); 418 + white-space: nowrap; 419 + } 420 + @media (max-width: 45rem) { 421 + table.coverage, 422 + table.coverage tbody, 423 + table.coverage tr, 424 + table.coverage th, 425 + table.coverage td { 426 + display: block; 427 + width: auto; 428 + } 429 + table.coverage thead { 430 + display: none; 431 + } 432 + table.coverage tr { 433 + border-block-end: 1px solid var(--ph-border); 434 + padding-block: 0.75rem; 435 + } 436 + table.coverage th, 437 + table.coverage td { 438 + border: 0; 439 + padding-inline: 0; 440 + } 441 + } 442 + 443 + /* ---- Start path ---- */ 444 + ol.steps { 445 + list-style: none; 446 + padding: 0; 447 + margin: 0; 448 + display: grid; 449 + gap: 1.25rem; 450 + } 451 + @media (min-width: 55rem) { 452 + ol.steps { 453 + grid-template-columns: repeat(3, 1fr); 454 + } 455 + } 456 + ol.steps li { 457 + border-block-start: 2px solid var(--ph-mark); 458 + padding-top: 0.9rem; 459 + display: flex; 460 + flex-direction: column; 461 + } 462 + .step-n { 463 + font-family: var(--__sl-font-mono); 464 + font-size: var(--sl-text-xs); 465 + color: var(--ph-mark); 466 + letter-spacing: 0.1em; 467 + } 468 + ol.steps p { 469 + font-size: var(--sl-text-sm); 470 + color: var(--sl-color-gray-2); 471 + margin-bottom: 0.9rem; 472 + } 473 + .step-link { 474 + margin-top: auto; 475 + font-size: var(--sl-text-sm); 476 + align-self: flex-start; 477 + } 478 + ol.steps :global(.expressive-code) { 479 + margin-bottom: 0.9rem; 480 + } 481 + </style>
+11 -36
site/src/content/docs/index.mdx
··· 1 1 --- 2 2 title: phonometry 3 - description: Acoustic measurement toolkit for Python covering sound level metrology, psychoacoustics, rooms and buildings, vibration, environment and transport, underwater acoustics, electroacoustics and wave simulation, conformance-tested against 235 IEC, ISO, EN, ANSI, ECMA, DIN, ITU, EBU, SAE, ICAO and ECAC standards. 3 + description: Acoustic measurement toolkit for Python covering sound level metrology, psychoacoustics, rooms and buildings, vibration, environment and transport, underwater acoustics, electroacoustics and wave simulation, with 427 conformance checks against 278 IEC, ISO, EN, ANSI, ECMA, DIN, ITU, EBU, SAE, ICAO and ECAC standards. 4 4 template: splash 5 5 hero: 6 - tagline: Acoustic measurement in Python, from octave-band metrology and Zwicker loudness to building insulation, aircraft EPNL and underwater propagation. 371 conformance checks against 235 standards, and every result renders its figure and, where a standard defines one, a normative PDF fiche. 6 + tagline: Acoustic measurement, analysis and prediction in Python. Every metric is implemented from the text of its standard, and CI checks it against that standard's own tolerance tables and worked examples on every pull request. 7 7 actions: 8 - - text: Get started 8 + - text: Install and first analysis 9 9 link: /phonometry/getting-started/ 10 10 icon: right-arrow 11 11 variant: primary 12 + - text: Conformance report 13 + link: /phonometry/reference/conformance/ 14 + icon: approve-check 15 + variant: minimal 12 16 - text: View on GitHub 13 17 link: https://github.com/jmrplens/phonometry 14 18 icon: github 19 + variant: minimal 15 20 --- 16 21 17 - import { CardGrid, Card } from '@astrojs/starlight/components'; 22 + import Home from '../../components/home/Home.astro'; 23 + import { en } from '../../data/home'; 18 24 19 - <CardGrid> 20 - <Card title="Conformance-tested" icon="approve-check"> 21 - Every metric is verified in CI against its standard's own tolerance tables, test vectors and worked examples: 371 numerical checks across 46 domains and 235 standards, from IEC 61672-1 weightings and IEC 61260-1 filters to ISO 532 loudness, ISO 717 ratings, ICAO Annex 16 EPNL and EBU R 128, regenerated on every pull request and published as the conformance report. 22 - </Card> 23 - <Card title="Core signal analysis" icon="magnifier"> 24 - Fractional octave banks (five SOS architectures), A/C/G/Z weighting, Fast/Slow/Impulse ballistics, Leq/LAeq, percentiles, LCpeak, SEL, noise dose, Lden and octave spectrograms, plus calibrated PSD/CSD spectral analysis, correlation and time-delay estimation, physical SPL calibration with IEC 60942 stability checks, GUM and Monte Carlo uncertainty, stateful real-time block processing and vectorized multichannel. 25 - </Card> 26 - <Card title="Hearing and perception" icon="comment"> 27 - Loudness in sones three ways (ISO 532-1/2/3 and the Sottek Hearing Model of ECMA-418-2), DIN 45692 sharpness, ECMA-418-2 tonality, roughness and fluctuation strength, psychoacoustic annoyance, equal-loudness contours (ISO 226), tone prominence (TNR/PR) and audibility, STI/STIPA per IEC 60268-16 Ed. 5 and SII per ANSI S3.5, plus hearing thresholds by age (ISO 7029), noise-induced hearing loss (ISO 1999) and occupational exposure LEX,8h (ISO 9612). 28 - </Card> 29 - <Card title="Rooms and buildings" icon="open-book"> 30 - Swept-sine and MLS impulse responses (ISO 18233), room parameters EDT/T20/T30/C50/C80/Ts and open-plan metrics (ISO 3382-1/2/3), room-noise criterion curves (ANSI/ASA S12.2), reverberation prediction (EN 12354-6), field and laboratory insulation with weighted ratings (ISO 16283-1/2/3, ISO 10140, ISO 717-1/2), flanking, façade and outdoor-radiation prediction (EN 12354-1/2/3/4), measurement uncertainty (ISO 12999-1) and material data from absorption (ISO 354, ISO 11654) to impedance-tube measurement (ISO 10534-2), porous-absorber models and scattering/diffusion (ISO 17497). 31 - </Card> 32 - <Card title="Vibration and structure-borne sound" icon="rocket"> 33 - The FRF vocabulary of ISO 7626 (receptance, mobility, accelerance), isolator transfer stiffness (ISO 10846), sound power radiated by vibrating surfaces (ISO/TS 7849), reception-plate structure-borne power (EN 15657), installed-source prediction in buildings (EN 12354-5) and junction transmission, plus whole-body, multiple-shock and hand-arm human vibration (ISO 2631-1/-5, ISO 5349). 34 - </Card> 35 - <Card title="Environment and transport" icon="open-book"> 36 - Atmospheric absorption α(f) (ISO 9613-1), the ISO 9613-2 general method with a per-term octave-band breakdown, ground effect, barriers and atmospheric refraction, impulsive-sound adjustments (NT ACOU 112), rating levels and Lden (ISO 1996), the aircraft EPNL certification chain (ICAO Annex 16, IEC 61265, SAE ARP 5534), ECAC Doc 29 airport contours on the EASA ANP fleet database, the ECAC Doc 32 rotorcraft hemisphere method and IEC 61400-11 wind-turbine emission and tonal audibility. 37 - </Card> 38 - <Card title="Underwater acoustics" icon="magnifier"> 39 - Levels re 1 µPa (ISO 18405), ship radiated noise and equivalent monopole source level (ISO 17208-1/2), pile-driving exposure (ISO 18406), sound speed in sea water, seabed reflection, Wenz and JOMOPANS-ECHO ambient noise, the passive and active sonar equations, and transmission loss from spreading-plus-absorption closed forms to normal-mode, ray-tracing and parabolic-equation solvers. 40 - </Card> 41 - <Card title="Sources and devices" icon="rocket"> 42 - Sound power by five standardised routes (ISO 3744/3746, ISO 3741, ISO 9614-2, ISO 3745, ISO 9614-3), p-p sound intensity (IEC 61043) with ISO 9614-1 field indicators, ISO 4871 noise-emission declarations, audio-equipment distortion and frequency response (IEC 60268-3), swept-sine THD(f) (Farina, Novak), loudspeaker and microphone rated characteristics (IEC 60268-5/-4), silencers, enclosures and HVAC noise, and programme loudness with true peak (ITU-R BS.1770-5, EBU R 128). 43 - </Card> 44 - <Card title="Wave simulation" icon="comment"> 45 - A deterministic 2D acoustic FDTD solver on a staggered pressure-velocity grid, with CW and pulse sources, pressure probes, rasterised obstacles and per-side rigid, impedance or absorbing boundaries, validated against analytic oracles and usable as a cross-check engine for the closed-form models. 46 - </Card> 47 - <Card title="Results you can hand over" icon="approve-check"> 48 - Every analysis returns a typed, frozen dataclass whose one-line <code>.plot()</code> renders the canonical figure in English or Spanish (<code>language="en"|"es"</code>), and where a standard defines a reporting format a <code>.report()</code> method renders the normative PDF fiche: ISO 717, ISO 11654, ISO 532-1, EBU R 128, ICAO EPNL, IEC 61260-1, ISO 4871 and IEC 60268-5/-4. The documentation is fully bilingual. 49 - </Card> 50 - </CardGrid> 25 + <Home content={en} lang="en" />
+11 -36
site/src/content/docs/es/index.mdx
··· 1 1 --- 2 2 title: phonometry 3 - description: Toolkit de medición acústica para Python que abarca la metrología de niveles sonoros, la psicoacústica, salas y edificación, la vibración, el medio ambiente y el transporte, la acústica submarina, la electroacústica y la simulación de ondas, verificado por conformidad frente a 235 normas IEC, ISO, EN, ANSI, ECMA, DIN, ITU, EBU, SAE, OACI y CEAC. 3 + description: Toolkit de medición acústica para Python que abarca la metrología de niveles sonoros, la psicoacústica, salas y edificación, la vibración, el medio ambiente y el transporte, la acústica submarina, la electroacústica y la simulación de ondas, con 427 comprobaciones de conformidad frente a 278 normas IEC, ISO, EN, ANSI, ECMA, DIN, ITU, EBU, SAE, OACI y CEAC. 4 4 template: splash 5 5 hero: 6 - tagline: Medición acústica en Python, desde la metrología en bandas de octava y la sonoridad de Zwicker hasta el aislamiento en edificación, el EPNL de aeronaves y la propagación submarina. 371 comprobaciones de conformidad frente a 235 normas, y cada resultado genera su figura y, cuando una norma lo define, una ficha PDF normativa. 6 + tagline: Medición, análisis y predicción acústica en Python. Cada métrica se implementa a partir del texto de su norma, y CI la comprueba frente a las tablas de tolerancia y los ejemplos resueltos de esa misma norma en cada pull request. 7 7 actions: 8 - - text: Empezar 8 + - text: Instalar y primer análisis 9 9 link: /phonometry/es/getting-started/ 10 10 icon: right-arrow 11 11 variant: primary 12 + - text: Informe de conformidad 13 + link: /phonometry/es/reference/conformance/ 14 + icon: approve-check 15 + variant: minimal 12 16 - text: Ver en GitHub 13 17 link: https://github.com/jmrplens/phonometry 14 18 icon: github 19 + variant: minimal 15 20 --- 16 21 17 - import { CardGrid, Card } from '@astrojs/starlight/components'; 22 + import Home from '../../../components/home/Home.astro'; 23 + import { es } from '../../../data/home'; 18 24 19 - <CardGrid> 20 - <Card title="Verificado por conformidad" icon="approve-check"> 21 - Cada métrica se verifica en CI frente a las tablas de tolerancia, vectores de prueba y ejemplos resueltos de su propia norma: 371 comprobaciones numéricas en 46 dominios y 235 normas, desde las ponderaciones de IEC 61672-1 y los filtros de IEC 61260-1 hasta la sonoridad ISO 532, los índices globales ISO 717, el EPNL del Anexo 16 de la OACI y EBU R 128, regeneradas en cada pull request y publicadas como informe de conformidad. 22 - </Card> 23 - <Card title="Análisis de señal" icon="magnifier"> 24 - Bancos de octava fraccionaria (cinco arquitecturas SOS), ponderación A/C/G/Z, ponderación temporal Fast/Slow/Impulse, Leq/LAeq, percentiles, LCpeak, SEL, dosis de ruido, Lden y espectrogramas de octava, además de análisis espectral calibrado PSD/CSD, correlación y estimación de retardos, calibración física de SPL con comprobación de estabilidad IEC 60942, incertidumbre GUM y Monte Carlo, procesado por bloques en tiempo real con estado y multicanal vectorizado. 25 - </Card> 26 - <Card title="Audición y percepción" icon="comment"> 27 - Sonoridad en sonos por tres vías (ISO 532-1/2/3 y el modelo de Sottek de ECMA-418-2), sharpness DIN 45692, tonalidad, aspereza y fuerza de fluctuación ECMA-418-2, molestia psicoacústica, líneas isofónicas (ISO 226), prominencia tonal (TNR/PR) y audibilidad, STI/STIPA según IEC 60268-16 Ed. 5 y SII según ANSI S3.5, además de umbrales de audición por edad (ISO 7029), pérdida auditiva inducida por ruido (ISO 1999) y exposición laboral LEX,8h (ISO 9612). 28 - </Card> 29 - <Card title="Salas y edificación" icon="open-book"> 30 - Respuestas al impulso por barrido sinusoidal y MLS (ISO 18233), parámetros de sala EDT/T20/T30/C50/C80/Ts y métricas de oficinas diáfanas (ISO 3382-1/2/3), curvas de criterio de ruido de fondo (ANSI/ASA S12.2), predicción de la reverberación (EN 12354-6), aislamiento en campo y en laboratorio con índices globales (ISO 16283-1/2/3, ISO 10140, ISO 717-1/2), predicción de flancos, fachadas y radiación al exterior (EN 12354-1/2/3/4), incertidumbre de medición (ISO 12999-1) y datos de materiales desde la absorción (ISO 354, ISO 11654) hasta el tubo de impedancia (ISO 10534-2), los modelos de absorbentes porosos y la dispersión y difusión (ISO 17497). 31 - </Card> 32 - <Card title="Vibración y ruido estructural" icon="rocket"> 33 - El vocabulario de FRF de ISO 7626 (receptancia, movilidad, acelerancia), la rigidez de transferencia de aisladores (ISO 10846), la potencia acústica radiada por superficies vibrantes (ISO/TS 7849), la potencia estructural en placa de recepción (EN 15657), la predicción de fuentes instaladas en edificios (EN 12354-5) y la transmisión en uniones, además de la vibración en humanos de cuerpo entero, por choques múltiples y mano-brazo (ISO 2631-1/-5, ISO 5349). 34 - </Card> 35 - <Card title="Medio ambiente y transporte" icon="open-book"> 36 - Absorción atmosférica α(f) (ISO 9613-1), el método general de ISO 9613-2 con desglose por término en bandas de octava, efecto del suelo, barreras y refracción atmosférica, ajustes por ruido impulsivo (NT ACOU 112), niveles de evaluación y Lden (ISO 1996), la cadena de certificación EPNL de aeronaves (Anexo 16 de la OACI, IEC 61265, SAE ARP 5534), los contornos aeroportuarios del Doc 29 de la CEAC sobre la base de datos de flota ANP de la EASA, el método de hemisferios de helicópteros del Doc 32 de la CEAC y la emisión y audibilidad tonal de aerogeneradores según IEC 61400-11. 37 - </Card> 38 - <Card title="Acústica submarina" icon="magnifier"> 39 - Niveles re 1 µPa (ISO 18405), ruido radiado por buques y nivel de fuente monopolar equivalente (ISO 17208-1/2), exposición por hincado de pilotes (ISO 18406), velocidad del sonido en agua de mar, reflexión en el lecho marino, ruido ambiente de Wenz y JOMOPANS-ECHO, las ecuaciones del sonar pasivo y activo, y pérdidas de transmisión desde las formas cerradas de divergencia más absorción hasta los métodos numéricos de modos normales, trazado de rayos y ecuación parabólica. 40 - </Card> 41 - <Card title="Fuentes y dispositivos" icon="rocket"> 42 - Potencia acústica por cinco vías normalizadas (ISO 3744/3746, ISO 3741, ISO 9614-2, ISO 3745, ISO 9614-3), intensidad acústica p-p (IEC 61043) con los indicadores de campo de ISO 9614-1, declaraciones de emisión de ruido ISO 4871, distorsión y respuesta en frecuencia de equipos de audio (IEC 60268-3), THD(f) por barrido sinusoidal (Farina, Novak), características nominales de altavoces y micrófonos (IEC 60268-5/-4), silenciadores, encapsulamientos y ruido de climatización, y sonoridad de programa con pico verdadero (ITU-R BS.1770-5, EBU R 128). 43 - </Card> 44 - <Card title="Simulación de ondas" icon="comment"> 45 - Un solver acústico FDTD 2D determinista sobre una malla escalonada de presión y velocidad, con fuentes continuas y de pulso, sondas de presión, obstáculos rasterizados y contornos rígidos, de impedancia o absorbentes por lado, validado frente a oráculos analíticos y utilizable como motor de contraste de los modelos de forma cerrada. 46 - </Card> 47 - <Card title="Resultados listos para entregar" icon="approve-check"> 48 - Cada análisis devuelve un dataclass tipado e inmutable cuyo <code>.plot()</code> de una línea genera la figura canónica en inglés o español (<code>language="en"|"es"</code>), y cuando una norma define un formato de informe, un método <code>.report()</code> genera la ficha PDF normativa: ISO 717, ISO 11654, ISO 532-1, EBU R 128, EPNL de la OACI, IEC 61260-1, ISO 4871 e IEC 60268-5/-4. La documentación es íntegramente bilingüe. 49 - </Card> 50 - </CardGrid> 25 + <Home content={es} lang="es" />