diff --git a/README.md b/README.md index f9bb21dd..f69f0ccc 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,23 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** fro | Category | Name | What they do | Link | |---|---|---|---| -| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | -| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) | -| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | Adrianna Claro | Soul and Contemporary R&B Singer, Songwriter and Composer | [Spotify](https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG) · [Apple Music](https://music.apple.com/us/album/happy/1592026727?i=1592026728) | | Artists & Creators | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | | Artists & Creators | Dead röses | Cork-based punk rock band | [@dead_rosesband](https://www.instagram.com/dead_rosesband) | -| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) | +| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | +| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) · [Spotify](https://open.spotify.com/artist/6nePYOqT4E7D4C48twPtKk) · [Apple Music](https://music.apple.com/us/artist/killah-trakz/371296633) | +| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) · [Spotify](https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV) | | Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | -| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | | Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) | -| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | -| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | +| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | +| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | +| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | | Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) | | Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) | -| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | -| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | -| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | +| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | | Writers & Storytellers | Alexandre Borges | Portuguese writer, screenwriter, and cultural commentator | [Books & author profile](https://www.instagram.com/alexgram_b/) | diff --git a/static/css/daw.css b/static/css/daw.css index d015e4a0..6131e9fc 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -4564,6 +4564,19 @@ input, textarea { font-family: inherit; } } .lib-friend:hover .lib-friend-ig, .lib-friend:hover .lib-friend-link { opacity: 1; } +/* A card with several links: the card itself is not a link, each pill is. */ +.lib-friend-multi:hover { background: var(--panel); border-color: var(--border); color: var(--fg-2); } +.lib-friend-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: auto; padding-top: 3px; } +.lib-friend-pill { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 7px; border-radius: 999px; + border: 1px solid var(--border-strong); background: var(--panel-2); + color: var(--fg-2); font-size: 9.5px; font-weight: 600; text-decoration: none; + transition: color var(--t-fast), border-color var(--t-fast); +} +.lib-friend-pill:hover { color: var(--fg); border-color: var(--accent); } +.lib-friend-pill .lib-friend-link, +.lib-friend-pill .lib-friend-ig { width: 10px; height: 10px; margin-top: 0; padding-top: 0; opacity: 1; } .lib-friend-name { width: 100%; font-size: 11px; @@ -5677,12 +5690,36 @@ input, textarea { font-family: inherit; } .rail-guide[aria-pressed="true"] { color: var(--accent); background: var(--panel); } /* Only the control being explained is marked, so the screen stays calm; the pill at the bottom says help mode is on. */ -.help-mode .help-target { cursor: help; } +/* With no song open, the studio turns several controls off for the pointer + (Export Mix, the song facts, the presence cards). Help mode still has to hear + the pointer over them to explain them, and it takes every click itself, so + nothing can be pressed by accident. */ +.help-mode .help-target { cursor: help; pointer-events: auto !important; } .help-ring { position: fixed; z-index: 9998; pointer-events: none; border: 2px solid var(--accent); border-radius: var(--radius-sm); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent); } +/* The glow when Guide turns on: every explainable control at once, fading in + and out over --intro-ms (set from INTRO_MS in helpMode.js). With reduced + motion it simply shows, still, for the same time. */ +.help-intro { position: fixed; inset: 0; z-index: 9997; pointer-events: none; } +.help-intro-ring { + position: absolute; + border: 2px solid var(--accent); border-radius: var(--radius-sm); + box-shadow: 0 0 10px 2px color-mix(in srgb, var(--accent) 35%, transparent); + opacity: 0; + animation: help-intro-glow var(--intro-ms, 1600ms) ease-in-out forwards; +} +@keyframes help-intro-glow { + 0% { opacity: 0; } + 25% { opacity: 1; } + 65% { opacity: 1; } + 100% { opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { + .help-intro-ring { animation: none; opacity: 0.8; } +} .help-pill { position: fixed; z-index: 9999; left: 50%; bottom: 18px; transform: translateX(-50%); max-width: calc(100vw - 32px); diff --git a/static/img/friends/adrianna-claro.webp b/static/img/friends/adrianna-claro.webp new file mode 100644 index 00000000..38f1e7f6 Binary files /dev/null and b/static/img/friends/adrianna-claro.webp differ diff --git a/static/js/catalog.js b/static/js/catalog.js index 06231b0e..e153d7a1 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -79,17 +79,34 @@ const FRIEND_GROUPS = [ { name: "Killah Trakz", roleKey: "friends.role.killahTrakz", - url: "https://www.instagram.com/killahtrakz/", + links: [ + { label: "Instagram", url: "https://www.instagram.com/killahtrakz/" }, + { label: "Spotify", url: "https://open.spotify.com/artist/6nePYOqT4E7D4C48twPtKk" }, + { label: "Apple Music", url: "https://music.apple.com/us/artist/killah-trakz/371296633" }, + ], logo: "/img/friends/killah-trakz.jpg", avatar: true, }, { name: "NIHIL", roleKey: "friends.role.nihil", - url: "https://www.instagram.com/somosnihil/", + links: [ + { label: "Instagram", url: "https://www.instagram.com/somosnihil/" }, + { label: "Spotify", url: "https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV" }, + ], logo: "/img/friends/nihil.jpg", avatar: true, }, + { + name: "Adrianna Claro", + roleKey: "friends.role.adriannaClaro", + logo: "/img/friends/adrianna-claro.webp", + avatar: true, + links: [ + { label: "Spotify", url: "https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG" }, + { label: "Apple Music", url: "https://music.apple.com/us/album/happy/1592026727?i=1592026728" }, + ], + }, ], }, { @@ -3523,13 +3540,23 @@ function friendGlyph(url) { return svg; } -// One partner card: image (or initial badge), name, role, link glyph. -function friendCard(f) { +/** A link that opens outside the app, as every partner link does. */ +function friendLink(url) { const a = document.createElement("a"); - a.className = "lib-friend"; - a.href = f.url; + a.href = url; a.target = "_blank"; a.rel = "noopener noreferrer"; + return a; +} + +// One partner card: image (or initial badge), name, role, link glyph. A card +// with one `url` is a link as a whole. A card with several `links` cannot be: +// a link inside a link is invalid HTML, and which one a click follows is up to +// the browser. So it is a plain card with one labelled link per destination. +function friendCard(f) { + const multi = Array.isArray(f.links) && f.links.length > 0; + const a = multi ? document.createElement("div") : friendLink(f.url); + a.className = multi ? "lib-friend lib-friend-multi" : "lib-friend"; a.title = f.name; // A monogram badge (first initial) keeps the card on-brand when an entry has @@ -3575,7 +3602,20 @@ function friendCard(f) { a.appendChild(role); } - a.appendChild(friendGlyph(f.url)); + if (!multi) { + a.appendChild(friendGlyph(f.url)); + return a; + } + const row = document.createElement("span"); + row.className = "lib-friend-links"; + for (const link of f.links) { + const l = friendLink(link.url); + l.className = "lib-friend-pill"; + l.title = `${f.name}: ${link.label}`; + l.append(friendGlyph(link.url), link.label); + row.appendChild(l); + } + a.appendChild(row); return a; } diff --git a/static/js/helpMode.js b/static/js/helpMode.js index 767b07e8..7749d4fe 100644 --- a/static/js/helpMode.js +++ b/static/js/helpMode.js @@ -12,6 +12,9 @@ import { t } from "./i18n.js"; /** Lane controls name their instrument in the text ("Silences the Drums"). */ const laneName = (el) => el.closest(".mx-row, .lane-header")?.querySelector(".mx-name")?.textContent.trim() || ""; const chipName = (el) => el.textContent.trim(); +/** The instrument a presence card measures, by its own name ("Drums"), not + * the card's heading ("Drum intensity"), so the sentence reads naturally. */ +const presenceName = (el) => t(`stem.${el.dataset.stem}`); // Order matters only where selectors overlap: the first match wins. const HELP = [ @@ -51,7 +54,13 @@ const HELP = [ [".mx-btn.mute", "help.mute", laneName], [".mx-btn.solo", "help.solo", laneName], [".lane-dl", "help.download", laneName], - ["#transport .daw-info-row", "help.songFacts", null, "help.songFactsTitle"], + ['.daw-meta-card[data-meta="key"]', "help.meta.key"], + ['.daw-meta-card[data-meta="bpm"]', "help.meta.bpm"], + ['.daw-meta-card[data-meta="lufs"]', "help.meta.lufs"], + ['.daw-meta-card[data-meta="duration"]', "help.meta.duration"], + ['.daw-meta-card[data-meta="dr"]', "help.meta.dr"], + ['.daw-meta-card[data-meta="stability"]', "help.meta.stability"], + [".stem-presence-panel .stem-card[data-stem]", "help.presence", presenceName], ["#ruler-time", "help.waves", null, "help.wavesTitle"], [".waves-column", "help.waves", null, "help.wavesTitle"], ["#t-stop", "help.stop"], @@ -108,6 +117,11 @@ let on = false; let tipEl = null; let pillEl = null; let ringEl = null; +let introEl = null; +let introTimer = null; +// How long the glow on every explainable control lasts when Guide turns on. +// daw.css reads it through --intro-ms, so the fade and the cleanup agree. +const INTRO_MS = 1600; let current = null; export function isHelpModeOn() { @@ -249,6 +263,40 @@ function markTargets() { } } +/** + * When Guide turns on, every control it can explain glows once and fades, so + * the user sees where to point without the screen staying busy. One ring per + * control, drawn the same way as the pointer's ring, so no panel clips them. + * Large areas (the waveforms, the song facts) are left out: a glowing box + * across half the screen says nothing about where to point. + */ +function playIntro() { + stopIntro(); + const vw = window.innerWidth, vh = window.innerHeight; + const big = vw * vh * 0.04; + for (const el of document.querySelectorAll(".help-target")) { + const r = visibleRect(el); + if (r.width * r.height > big || r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) continue; + const ring = document.createElement("div"); + ring.className = "help-intro-ring"; + ring.style.left = `${Math.round(r.left)}px`; + ring.style.top = `${Math.round(r.top)}px`; + ring.style.width = `${Math.round(r.width)}px`; + ring.style.height = `${Math.round(r.height)}px`; + introEl.appendChild(ring); + } + introEl.hidden = false; + introTimer = setTimeout(stopIntro, INTRO_MS); +} + +function stopIntro() { + clearTimeout(introTimer); + introTimer = null; + if (!introEl) return; + introEl.hidden = true; + introEl.replaceChildren(); +} + function onPointerOver(e) { const entry = findEntry(e.target); if (entry) show(entry); @@ -302,6 +350,7 @@ export function setHelpMode(next) { window.addEventListener("resize", hide); document.addEventListener("scroll", hide, true); markTargets(); + playIntro(); } else { document.removeEventListener("pointerover", onPointerOver, true); for (const type of PRESS_EVENTS) document.removeEventListener(type, swallow, true); @@ -310,6 +359,7 @@ export function setHelpMode(next) { window.removeEventListener("resize", hide); document.removeEventListener("scroll", hide, true); hide(); + stopIntro(); markTargets(); } } @@ -331,6 +381,11 @@ export function initHelpMode() { ringEl.className = "help-ring"; ringEl.setAttribute("aria-hidden", "true"); ringEl.hidden = true; - document.body.append(ringEl, tipEl, pillEl); + introEl = document.createElement("div"); + introEl.className = "help-intro"; + introEl.setAttribute("aria-hidden", "true"); + introEl.style.setProperty("--intro-ms", `${INTRO_MS}ms`); + introEl.hidden = true; + document.body.append(introEl, ringEl, tipEl, pillEl); btn.addEventListener("click", () => setHelpMode(!on)); } diff --git a/static/js/i18n.js b/static/js/i18n.js index d6e9f29b..015dcfe9 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -481,8 +481,13 @@ const en = { "help.mute": "Silences {name}, so you can play or sing that part yourself.", "help.solo": "Plays only {name}, to hear it clearly.", "help.download": "Saves {name} as its own audio file.", - "help.songFactsTitle": "Song facts", - "help.songFacts": "Found for you: the key, the tempo in BPM, loudness, length and how much of each instrument the song has.", + "help.meta.key": "The song's key, found by listening to it. The small percentage says how sure that guess is.", + "help.meta.bpm": "The tempo, in beats per minute. The click track follows it.", + "help.meta.lufs": "How loud the song is on average, in LUFS. Closer to zero is louder; most streaming services play songs at about -14.", + "help.meta.duration": "How long the song is.", + "help.meta.dr": "The gap between the loudest moments and the average level, in dB. A higher number means more contrast between quiet and loud parts.", + "help.meta.stability": "How steady the tempo is. 100% means it never drifts; a lower number means it speeds up or slows down.", + "help.presence": "How loud the {name} part is next to the loudest instrument in the song. 100% is the loudest; a dash means it is not in the song.", "help.wavesTitle": "Waveforms", "help.waves": "Each instrument's sound, drawn over time. Click to jump to that moment. Drag across to loop that part.", "help.stop": "Stops the song.", @@ -733,6 +738,7 @@ const en = { "friends.role.deadRoses": "Cork-based punk rock band", "friends.role.killahTrakz": "Industry Secret Villain and Media Influencer", "friends.role.nihil": "Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness", + "friends.role.adriannaClaro": "Soul and Contemporary R&B Singer, Songwriter and Composer", "friends.role.empressEffects": "Boutique effects pedals", "friends.role.thomann": "Large music-equipment retailer", "friends.role.beltr": "Local, subscription-free karaoke software", @@ -1371,8 +1377,13 @@ const pl = { "help.mute": "Wycisza {name}, żebyś mógł sam zagrać lub zaśpiewać tę partię.", "help.solo": "Odtwarza tylko {name}, żeby było wyraźnie słychać.", "help.download": "Zapisuje {name} jako osobny plik audio.", - "help.songFactsTitle": "Dane utworu", - "help.songFacts": "Wyznaczone dla ciebie: tonacja, tempo w BPM, głośność, długość i udział każdego instrumentu.", + "help.meta.key": "Tonacja utworu, rozpoznana ze słuchu. Mały procent mówi, jak pewne jest to rozpoznanie.", + "help.meta.bpm": "Tempo w uderzeniach na minutę. Klik podąża za nim.", + "help.meta.lufs": "Średnia głośność utworu w LUFS. Bliżej zera znaczy głośniej; większość serwisów streamingowych odtwarza utwory na poziomie około -14.", + "help.meta.duration": "Długość utworu.", + "help.meta.dr": "Różnica między najgłośniejszymi momentami a średnim poziomem, w dB. Wyższa liczba oznacza większy kontrast między cichymi i głośnymi fragmentami.", + "help.meta.stability": "Jak równe jest tempo. 100% oznacza, że się nie zmienia; niższa liczba oznacza, że przyspiesza lub zwalnia.", + "help.presence": "Jak głośne jest {name} w porównaniu z najgłośniejszym instrumentem w utworze. 100% to najgłośniejszy; kreska oznacza, że go nie ma.", "help.wavesTitle": "Przebiegi", "help.waves": "Dźwięk każdego instrumentu w czasie. Kliknij, aby przejść do tego momentu. Przeciągnij, aby zapętlić ten fragment.", "help.stop": "Zatrzymuje utwór.", @@ -1621,6 +1632,7 @@ const pl = { "friends.role.deadRoses": "Zespół punkrockowy z Corku", "friends.role.killahTrakz": "Sekretny złoczyńca branży i influencer medialny", "friends.role.nihil": "Nowoczesny metal alternatywny z Argentyny. Napędzany emocjonalnym wokalem, gniewem i pustką", + "friends.role.adriannaClaro": "Wokalistka soul i współczesnego R&B, autorka piosenek i kompozytorka", "friends.role.empressEffects": "Butikowe efekty gitarowe", "friends.role.thomann": "Duży sklep ze sprzętem muzycznym", "friends.role.beltr": "Lokalne karaoke bez abonamentu", @@ -2251,8 +2263,13 @@ const ja = { "help.mute": "{name}をミュートします。そのパートを自分で演奏したり歌ったりできます。", "help.solo": "{name}だけを再生して、はっきり聞けるようにします。", "help.download": "{name}を単独の音声ファイルとして保存します。", - "help.songFactsTitle": "曲の情報", - "help.songFacts": "キー、BPM で表したテンポ、ラウドネス、長さ、各楽器の割合を自動で調べています。", + "help.meta.key": "曲を聴いて判定したキーです。小さなパーセントは、その判定の確かさです。", + "help.meta.bpm": "1 分あたりの拍数で表したテンポです。クリックはこれに合わせます。", + "help.meta.lufs": "曲の平均的な音量(LUFS)です。0 に近いほど大きく、多くの配信サービスは約 -14 で再生します。", + "help.meta.duration": "曲の長さです。", + "help.meta.dr": "最も大きい瞬間と平均レベルの差(dB)です。数値が大きいほど、静かな部分と大きな部分の差がはっきりしています。", + "help.meta.stability": "テンポの安定度です。100% はまったく揺れないこと、数値が低いほど速くなったり遅くなったりします。", + "help.presence": "曲の中で最も大きい楽器と比べた{name}の音量です。100% が最大、ダッシュはその楽器が入っていないことを示します。", "help.wavesTitle": "波形", "help.waves": "各楽器の音を時間に沿って描いたものです。クリックでその位置へ移動し、ドラッグでその部分をループします。", "help.stop": "曲を止めます。", @@ -2500,6 +2517,7 @@ const ja = { "friends.role.deadRoses": "コーク拠点のパンクロックバンド", "friends.role.killahTrakz": "業界の隠れた悪役でメディアインフルエンサー", "friends.role.nihil": "アルゼンチン発のモダンなオルタナティヴメタル。感情的なボーカル、怒り、そして虚無が原動力", + "friends.role.adriannaClaro": "ソウルとコンテンポラリーR&Bのシンガー、ソングライター、作曲家", "friends.role.empressEffects": "ブティック系エフェクターペダル", "friends.role.thomann": "大手の楽器機材販売店", "friends.role.beltr": "サブスク不要、ローカルで動くカラオケソフト", @@ -3106,8 +3124,13 @@ const zhHans = { "help.mute": "将{name}静音,让你自己演奏或演唱这一部分。", "help.solo": "只播放{name},听得更清楚。", "help.download": "把{name}保存为单独的音频文件。", - "help.songFactsTitle": "歌曲信息", - "help.songFacts": "已为你分析:调性、以 BPM 表示的速度、响度、时长,以及每种乐器的占比。", + "help.meta.key": "通过聆听判断出的歌曲调性。小百分比表示这个判断有多可靠。", + "help.meta.bpm": "速度,以每分钟拍数表示。节拍声跟随它。", + "help.meta.lufs": "歌曲的平均响度,单位 LUFS。越接近零越响;大多数流媒体服务以约 -14 播放歌曲。", + "help.meta.duration": "歌曲的时长。", + "help.meta.dr": "最响时刻与平均电平之间的差值,单位 dB。数字越大,安静和响亮部分的对比越明显。", + "help.meta.stability": "速度有多稳定。100% 表示完全不漂移;数字越低,表示时快时慢。", + "help.presence": "{name}与歌曲中最响的乐器相比有多响。100% 为最响;横线表示歌曲中没有它。", "help.wavesTitle": "波形", "help.waves": "每种乐器的声音随时间的变化。点击跳到那一刻,拖动可循环那一段。", "help.stop": "停止播放。", @@ -3355,6 +3378,7 @@ const zhHans = { "friends.role.deadRoses": "来自科克的朋克摇滚乐队", "friends.role.killahTrakz": "行业隐秘反派与媒体影响者", "friends.role.nihil": "来自阿根廷的现代另类金属。以饱含情感的人声、愤怒与虚无为驱动", + "friends.role.adriannaClaro": "灵魂乐与当代 R&B 歌手、词曲作者和作曲家", "friends.role.empressEffects": "精品效果器", "friends.role.thomann": "大型乐器器材零售商", "friends.role.beltr": "本地运行、无需订阅的卡拉OK软件", @@ -3961,8 +3985,13 @@ const de = { "help.mute": "Schaltet {name} stumm, damit du diesen Part selbst spielen oder singen kannst.", "help.solo": "Spielt nur {name}, damit du es deutlich hörst.", "help.download": "Speichert {name} als eigene Audiodatei.", - "help.songFactsTitle": "Songdaten", - "help.songFacts": "Für dich ermittelt: Tonart, Tempo in BPM, Lautheit, Länge und wie viel von jedem Instrument der Song hat.", + "help.meta.key": "Die Tonart des Songs, durch Hören ermittelt. Die kleine Prozentzahl sagt, wie sicher diese Einschätzung ist.", + "help.meta.bpm": "Das Tempo in Schlägen pro Minute. Der Klick folgt ihm.", + "help.meta.lufs": "Wie laut der Song im Durchschnitt ist, in LUFS. Näher an null ist lauter; die meisten Streamingdienste spielen Songs bei etwa -14.", + "help.meta.duration": "Wie lang der Song ist.", + "help.meta.dr": "Der Abstand zwischen den lautesten Momenten und dem Durchschnittspegel, in dB. Eine höhere Zahl heißt mehr Kontrast zwischen leisen und lauten Teilen.", + "help.meta.stability": "Wie gleichmäßig das Tempo ist. 100 % heißt, es schwankt nie; eine niedrigere Zahl heißt, es wird schneller oder langsamer.", + "help.presence": "Wie laut {name} im Vergleich zum lautesten Instrument im Song ist. 100 % ist das lauteste; ein Strich heißt, es kommt nicht vor.", "help.wavesTitle": "Wellenformen", "help.waves": "Der Klang jedes Instruments über die Zeit. Klick, um an diese Stelle zu springen. Zieh darüber, um diesen Teil zu loopen.", "help.stop": "Stoppt den Song.", @@ -4211,6 +4240,7 @@ const de = { "friends.role.deadRoses": "Punkrock-Band aus Cork", "friends.role.killahTrakz": "Heimlicher Bösewicht der Branche und Medien-Influencer", "friends.role.nihil": "Moderner Alternative Metal aus Argentinien. Angetrieben von emotionsgeladenem Gesang, Wut und Leere", + "friends.role.adriannaClaro": "Soul- und Contemporary-R&B-Sängerin, Songwriterin und Komponistin", "friends.role.empressEffects": "Boutique-Effektpedale", "friends.role.thomann": "Großer Händler für Musik-Equipment", "friends.role.beltr": "Lokale Karaoke-Software ohne Abo", @@ -4827,8 +4857,13 @@ const pt = { "help.mute": "Silencia {name}, para você tocar ou cantar essa parte.", "help.solo": "Toca só {name}, para ouvir com clareza.", "help.download": "Salva {name} como um arquivo de áudio separado.", - "help.songFactsTitle": "Dados da música", - "help.songFacts": "Encontrados para você: o tom, o andamento em BPM, o volume, a duração e quanto há de cada instrumento.", + "help.meta.key": "O tom da música, encontrado ao ouvi-la. A pequena porcentagem diz o quão certo é esse palpite.", + "help.meta.bpm": "O andamento, em batidas por minuto. O clique segue ele.", + "help.meta.lufs": "O volume médio da música, em LUFS. Mais perto de zero é mais alto; a maioria dos serviços de streaming toca músicas em cerca de -14.", + "help.meta.duration": "A duração da música.", + "help.meta.dr": "A diferença entre os momentos mais altos e o nível médio, em dB. Um número maior significa mais contraste entre partes baixas e altas.", + "help.meta.stability": "O quão estável é o andamento. 100% significa que nunca varia; um número menor significa que acelera ou desacelera.", + "help.presence": "O quão alto {name} está em relação ao instrumento mais alto da música. 100% é o mais alto; um traço significa que não está na música.", "help.wavesTitle": "Formas de onda", "help.waves": "O som de cada instrumento ao longo do tempo. Clique para pular para esse momento. Arraste para repetir esse trecho em loop.", "help.stop": "Para a música.", @@ -5077,6 +5112,7 @@ const pt = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Vilão secreto da indústria e influenciador digital", "friends.role.nihil": "Metal alternativo moderno da Argentina. Movido por vocais carregados de emoção, raiva e vazio", + "friends.role.adriannaClaro": "Cantora, compositora e autora de soul e R&B contemporâneo", "friends.role.empressEffects": "Pedais de efeito boutique", "friends.role.thomann": "Grande varejista de equipamentos musicais", "friends.role.beltr": "Software de karaokê local, sem assinatura", @@ -5695,8 +5731,13 @@ const id = { "help.mute": "Membisukan {name}, agar Anda bisa memainkan atau menyanyikan bagian itu sendiri.", "help.solo": "Hanya memutar {name}, agar terdengar jelas.", "help.download": "Menyimpan {name} sebagai file audio tersendiri.", - "help.songFactsTitle": "Data lagu", - "help.songFacts": "Ditemukan untuk Anda: kunci nada, tempo dalam BPM, kekerasan suara, durasi, dan seberapa banyak tiap instrumen.", + "help.meta.key": "Kunci nada lagu, ditemukan dengan mendengarkannya. Persentase kecil menunjukkan seberapa yakin tebakan itu.", + "help.meta.bpm": "Tempo, dalam ketukan per menit. Ketukan klik mengikutinya.", + "help.meta.lufs": "Seberapa keras lagu secara rata-rata, dalam LUFS. Lebih dekat ke nol berarti lebih keras; kebanyakan layanan streaming memutar lagu di sekitar -14.", + "help.meta.duration": "Panjang lagu.", + "help.meta.dr": "Selisih antara momen paling keras dan tingkat rata-rata, dalam dB. Angka lebih tinggi berarti kontras lebih besar antara bagian pelan dan keras.", + "help.meta.stability": "Seberapa stabil temponya. 100% berarti tidak pernah bergeser; angka lebih rendah berarti tempo cepat atau melambat.", + "help.presence": "Seberapa keras {name} dibanding instrumen paling keras di lagu. 100% adalah yang paling keras; tanda strip berarti tidak ada di lagu.", "help.wavesTitle": "Bentuk gelombang", "help.waves": "Suara tiap instrumen dari waktu ke waktu. Klik untuk melompat ke saat itu. Seret untuk mengulang bagian itu.", "help.stop": "Menghentikan lagu.", @@ -5944,6 +5985,7 @@ const id = { "friends.role.deadRoses": "Band punk rock asal Cork", "friends.role.killahTrakz": "Penjahat Rahasia Industri dan Influencer Media", "friends.role.nihil": "Metal alternatif modern dari Argentina. Digerakkan oleh vokal penuh emosi, amarah, dan kehampaan", + "friends.role.adriannaClaro": "Penyanyi, penulis lagu, dan komposer soul dan R&B kontemporer", "friends.role.empressEffects": "Pedal efek butik", "friends.role.thomann": "Peritel besar perangkat musik", "friends.role.beltr": "Perangkat lunak karaoke lokal tanpa langganan", @@ -6550,8 +6592,13 @@ const fr = { "help.mute": "Coupe {name}, pour que vous puissiez jouer ou chanter cette partie vous-même.", "help.solo": "Joue seulement {name}, pour l'entendre clairement.", "help.download": "Enregistre {name} comme fichier audio séparé.", - "help.songFactsTitle": "Infos du morceau", - "help.songFacts": "Trouvés pour vous : la tonalité, le tempo en BPM, le volume, la durée et la présence de chaque instrument.", + "help.meta.key": "La tonalité du morceau, trouvée à l'écoute. Le petit pourcentage indique la certitude de cette estimation.", + "help.meta.bpm": "Le tempo, en battements par minute. Le clic le suit.", + "help.meta.lufs": "Le volume moyen du morceau, en LUFS. Plus proche de zéro, c'est plus fort ; la plupart des plateformes de streaming diffusent vers -14.", + "help.meta.duration": "La durée du morceau.", + "help.meta.dr": "L'écart entre les moments les plus forts et le niveau moyen, en dB. Un nombre plus élevé signifie plus de contraste entre passages doux et forts.", + "help.meta.stability": "La régularité du tempo. 100 % signifie qu'il ne varie jamais ; un nombre plus bas signifie qu'il accélère ou ralentit.", + "help.presence": "Le volume de {name} par rapport à l'instrument le plus fort du morceau. 100 % est le plus fort ; un tiret signifie qu'il n'est pas dans le morceau.", "help.wavesTitle": "Formes d'onde", "help.waves": "Le son de chaque instrument dans le temps. Cliquez pour aller à ce moment. Faites glisser pour boucler ce passage.", "help.stop": "Arrête le morceau.", @@ -6800,6 +6847,7 @@ const fr = { "friends.role.deadRoses": "Groupe de punk rock basé à Cork", "friends.role.killahTrakz": "Méchant secret de l'industrie et influenceur média", "friends.role.nihil": "Metal alternatif moderne d'Argentine. Porté par un chant chargé d'émotion, la colère et le vide", + "friends.role.adriannaClaro": "Chanteuse, autrice et compositrice de soul et de R&B contemporain", "friends.role.empressEffects": "Pédales d'effets boutique", "friends.role.thomann": "Grand détaillant de matériel de musique", "friends.role.beltr": "Logiciel de karaoké local, sans abonnement", @@ -7590,8 +7638,13 @@ const es = { "help.mute": "Silencia {name}, para que puedas tocar o cantar esa parte tú.", "help.solo": "Reproduce solo {name}, para oírlo con claridad.", "help.download": "Guarda {name} como un archivo de audio aparte.", - "help.songFactsTitle": "Datos de la canción", - "help.songFacts": "Calculados para ti: la tonalidad, el tempo en BPM, el volumen, la duración y cuánto hay de cada instrumento.", + "help.meta.key": "La tonalidad de la canción, encontrada al escucharla. El pequeño porcentaje indica cuán segura es esa estimación.", + "help.meta.bpm": "El tempo, en pulsos por minuto. El clic lo sigue.", + "help.meta.lufs": "El volumen medio de la canción, en LUFS. Más cerca de cero es más fuerte; la mayoría de servicios de streaming reproducen canciones a unos -14.", + "help.meta.duration": "La duración de la canción.", + "help.meta.dr": "La diferencia entre los momentos más fuertes y el nivel medio, en dB. Un número mayor significa más contraste entre partes suaves y fuertes.", + "help.meta.stability": "Qué tan estable es el tempo. 100% significa que nunca varía; un número menor significa que se acelera o se frena.", + "help.presence": "Qué tan fuerte suena {name} frente al instrumento más fuerte de la canción. 100% es el más fuerte; un guion significa que no está en la canción.", "help.wavesTitle": "Formas de onda", "help.waves": "El sonido de cada instrumento a lo largo del tiempo. Haz clic para saltar a ese momento. Arrastra para repetir esa parte en bucle.", "help.stop": "Detiene la canción.", @@ -7840,6 +7893,7 @@ const es = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Villano secreto de la industria e influencer de medios", "friends.role.nihil": "Metal alternativo moderno de Argentina. Impulsado por voces cargadas de emoción, rabia y vacío", + "friends.role.adriannaClaro": "Cantante, compositora y autora de soul y R&B contemporáneo", "friends.role.empressEffects": "Pedales de efectos boutique", "friends.role.thomann": "Gran tienda de equipo musical", "friends.role.beltr": "Software de karaoke local y sin suscripción", @@ -8479,8 +8533,13 @@ const ko = { "help.mute": "{name} 소리를 꺼서 그 부분을 직접 연주하거나 부를 수 있게 해요.", "help.solo": "{name}만 재생해서 또렷하게 들려줘요.", "help.download": "{name}을(를) 별도의 오디오 파일로 저장해요.", - "help.songFactsTitle": "곡 정보", - "help.songFacts": "자동으로 찾아 줘요: 키, BPM 단위의 템포, 음량, 길이, 그리고 악기별 비중이에요.", + "help.meta.key": "곡을 들어서 찾은 키예요. 작은 퍼센트는 그 판단이 얼마나 확실한지 보여 줘요.", + "help.meta.bpm": "분당 박자 수로 나타낸 템포예요. 클릭이 이를 따라가요.", + "help.meta.lufs": "곡의 평균 음량(LUFS)이에요. 0에 가까울수록 크고, 대부분의 스트리밍 서비스는 약 -14로 재생해요.", + "help.meta.duration": "곡의 길이예요.", + "help.meta.dr": "가장 큰 순간과 평균 레벨의 차이(dB)예요. 숫자가 클수록 조용한 부분과 큰 부분의 대비가 커요.", + "help.meta.stability": "템포가 얼마나 일정한지예요. 100%는 전혀 흔들리지 않는다는 뜻이고, 낮을수록 빨라지거나 느려져요.", + "help.presence": "곡에서 가장 큰 악기와 비교한 {name}의 음량이에요. 100%가 가장 크고, 대시는 곡에 없다는 뜻이에요.", "help.wavesTitle": "파형", "help.waves": "악기마다 소리를 시간에 따라 그린 거예요. 클릭하면 그 순간으로 가고, 끌면 그 부분을 반복해요.", "help.stop": "곡을 멈춰요.", @@ -8728,6 +8787,7 @@ const ko = { "friends.role.deadRoses": "코크를 기반으로 활동하는 펑크 록 밴드", "friends.role.killahTrakz": "업계의 숨은 악당이자 미디어 인플루언서", "friends.role.nihil": "아르헨티나에서 온 모던 얼터너티브 메탈. 감정적인 보컬과 분노, 공허가 원동력", + "friends.role.adriannaClaro": "소울과 컨템퍼러리 R&B 가수, 송라이터, 작곡가", "friends.role.empressEffects": "부티크 이펙터 페달", "friends.role.thomann": "대형 음악 장비 판매점", "friends.role.beltr": "구독이 필요 없는 로컬 노래방 소프트웨어", diff --git a/tests/e2e/help-mode.spec.mjs b/tests/e2e/help-mode.spec.mjs index cdf7ac3b..a11ebc4e 100644 --- a/tests/e2e/help-mode.spec.mjs +++ b/tests/e2e/help-mode.spec.mjs @@ -5,7 +5,7 @@ import { test, expect } from "@playwright/test"; -import { openStudio } from "./helpers.mjs"; +import { openStudio, seedLibrary, stubUpdateCheck } from "./helpers.mjs"; const tip = (page) => page.locator("#helpTip"); @@ -29,6 +29,23 @@ test.describe("help mode", () => { await expect(page.locator(".help-pill")).toBeHidden(); }); + test("turning Guide on makes every explainable control glow once, then fade", async ({ page }) => { + await openStudio(page); + await page.locator("#helpModeBtn").click(); + const rings = page.locator(".help-intro-ring"); + expect(await rings.count()).toBeGreaterThan(20); + await expect(page.locator(".help-intro")).toBeVisible(); + // Gone by itself, so the screen is calm again. + await expect(rings).toHaveCount(0, { timeout: 4000 }); + await expect(page.locator(".help-intro")).toBeHidden(); + + // Leaving Guide mid-glow clears it at once. + await page.keyboard.press("Escape"); + await page.locator("#helpModeBtn").click(); + await page.keyboard.press("Escape"); + await expect(rings).toHaveCount(0); + }); + test("pointing at a control explains it, under its own label", async ({ page }) => { await openStudio(page); await helpOn(page); @@ -53,6 +70,31 @@ test.describe("help mode", () => { await expect(tip(page).locator("b")).toHaveText("Key of Drums"); }); + test("each song fact and presence card explains itself", async ({ page }) => { + await openStudio(page); + await helpOn(page); + await page.locator('.daw-meta-card[data-meta="lufs"]').hover(); + await expect(tip(page)).toContainText("streaming services"); + await page.locator('.daw-meta-card[data-meta="stability"]').hover(); + await expect(tip(page)).toContainText("How steady the tempo is"); + await page.locator('.stem-presence-panel .stem-card[data-stem="drums"]').hover(); + await expect(tip(page)).toContainText("How loud the Drums part is"); + }); + + test("with no song open, controls the studio switches off still explain themselves", async ({ page }) => { + await seedLibrary(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator(".cat-item").first().waitFor(); + await helpOn(page); + for (const sel of ["#t-export-btn", "#np-details-btn", '.daw-meta-card[data-meta="key"]', '.stem-presence-panel .stem-card[data-stem="vocals"]']) { + const box = await page.locator(sel).first().boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await expect(tip(page), sel).toBeVisible(); + await expect(page.locator(sel).first(), sel).toHaveClass(/help-current/); + } + }); + test("a click explains instead of acting", async ({ page }) => { await openStudio(page); await helpOn(page); diff --git a/tests/e2e/we-recommend.spec.mjs b/tests/e2e/we-recommend.spec.mjs new file mode 100644 index 00000000..9a157960 --- /dev/null +++ b/tests/e2e/we-recommend.spec.mjs @@ -0,0 +1,53 @@ +// We Recommend: a partner card with one link is a link as a whole; a card with +// several (Adrianna Claro: Spotify and Apple Music) is a plain card with one +// labelled link per destination, since a link cannot hold other links. + +import { test, expect } from "@playwright/test"; + +import { seedLibrary, stubUpdateCheck } from "./helpers.mjs"; + +async function openWeRecommend(page) { + await seedLibrary(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#friendsBtn").click(); +} + +test.describe("We Recommend", () => { + test("a card with two links offers both, each opening outside the app", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Adrianna Claro" }); + await expect(card).toHaveCount(1); + await expect(card).toBeVisible(); + // Not a link itself, so the two below are the only things to click. + expect(await card.evaluate((el) => el.tagName)).toBe("DIV"); + // Her photo loads, rather than falling back to the monogram. + await expect(card.locator("img.lib-friend-avatar")).toHaveAttribute("src", "/img/friends/adrianna-claro.webp"); + await expect.poll(() => card.locator("img.lib-friend-avatar").evaluate((img) => img.naturalWidth)).toBeGreaterThan(0); + + const spotify = card.locator(".lib-friend-pill", { hasText: "Spotify" }); + const apple = card.locator(".lib-friend-pill", { hasText: "Apple Music" }); + await expect(spotify).toHaveAttribute("href", "https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG"); + await expect(apple).toHaveAttribute("href", "https://music.apple.com/us/album/happy/1592026727?i=1592026728"); + for (const link of [spotify, apple]) { + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + } + }); + + test("a card with one link is still a link as a whole", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Analog4Lyfe" }); + expect(await card.evaluate((el) => el.tagName)).toBe("A"); + await expect(card).toHaveAttribute("href", "https://www.instagram.com/analog4lyfe"); + await expect(card.locator(".lib-friend-pill")).toHaveCount(0); + }); + + test("Killah Trakz and NIHIL offer Instagram and Spotify, and Killah Trakz Apple Music too", async ({ page }) => { + await openWeRecommend(page); + const pills = (name) => page.locator(".lib-friend").filter({ hasText: name }).locator(".lib-friend-pill"); + await expect(pills("Killah Trakz")).toHaveText(["Instagram", "Spotify", "Apple Music"]); + await expect(pills("NIHIL")).toHaveText(["Instagram", "Spotify"]); + await expect(pills("NIHIL").filter({ hasText: "Spotify" })).toHaveAttribute("href", "https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV"); + }); +});