Compare commits
80 Commits
master
...
17db811bba
| Author | SHA1 | Date | |
|---|---|---|---|
| 17db811bba | |||
| b56b41996b | |||
| 7b9e072713 | |||
| 4cb2d085ca | |||
| a737ccf108 | |||
| a0ac48ccbf | |||
| 751637d2f1 | |||
| 7a7ad9f0a7 | |||
| b791beed9c | |||
| 14b5133543 | |||
| ecb35dd5b5 | |||
| 4428357e13 | |||
| 34f2f016f9 | |||
| d465a74cde | |||
| a55cc4f67f | |||
| 30065bb9e2 | |||
| 1254790374 | |||
| c74cb09324 | |||
| 077c4e819f | |||
| 459599d2ba | |||
| 92305bece0 | |||
| 2d0caeb732 | |||
| 92644e9519 | |||
| f67f84ded3 | |||
| 34656a8517 | |||
| 1da2274076 | |||
| 2a629faf50 | |||
| 21d65cad9e | |||
| 6981890be0 | |||
| 5af5c54c62 | |||
| 0f08e1d16c | |||
| 61104d1e25 | |||
| 683cda35cb | |||
| 2db4368fc9 | |||
| 9fd106b757 | |||
| 794126bfdb | |||
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c | |||
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 | |||
| e243862769 | |||
| b8f9f6d537 | |||
| 57ead93497 | |||
| 7f5e2733c2 | |||
| f7a5d24d8d | |||
| a5210fead2 | |||
| 498243af46 | |||
| f97de193d8 | |||
| 7e5fd5ab75 | |||
| fbf03a9847 | |||
| df89acd9a8 | |||
| 829ea89386 | |||
| a3b2c0ce2f | |||
| ba7f082f48 | |||
| 69119bb72a | |||
| 747c390303 | |||
| 57afc32f4a | |||
| 2e0e6ad199 | |||
| 8aed3b022b | |||
| a8ed350aec | |||
| edb6e5e027 | |||
| d44c24ec97 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,14 @@
|
||||
generated_3d
|
||||
venv
|
||||
.venv
|
||||
build
|
||||
*.egg-info
|
||||
*.FCStd1
|
||||
*.blend1
|
||||
__pycache__
|
||||
.ipynb_checkpoints
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.envrc
|
||||
.direnv
|
||||
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.tcc": "cpp",
|
||||
"deque": "cpp",
|
||||
"string": "cpp",
|
||||
"unordered_map": "cpp",
|
||||
"vector": "cpp",
|
||||
"system_error": "cpp"
|
||||
}
|
||||
}
|
||||
27
claude-design/AppHeader.dc.html
Normal file
27
claude-design/AppHeader.dc.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;padding:14px 78px 8px 32px;">
|
||||
<a href="{{ linkHref }}" style="justify-self:start;text-decoration:none;background:oklch(97% 0.01 230 / .95);border-radius:999px;padding:9px 16px;font-size:14px;font-weight:800;color:oklch(30% 0.03 230);display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 18px oklch(15% 0.04 230 / .4);white-space:nowrap;">{{ linkLabel }}</a>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<img src="{{ mascot }}" alt="Delfin" style="width:82px;height:82px;object-fit:contain;flex:none;filter:drop-shadow(0 4px 10px oklch(15% 0.04 230 / .45));">
|
||||
<div style="font-size:44px;font-weight:900;color:oklch(97% 0.01 230);letter-spacing:.5px;white-space:nowrap;">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<div style="justify-self:end;display:flex;align-items:center;gap:8px;">
|
||||
<sc-for list="{{ actions }}" as="action" hint-placeholder-count="0">
|
||||
<button onClick="{{ action.onClick }}" style="{{ action.style }}">{{ action.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1180, "height": 100}, "title": {"editor": "text", "default": "Musik Delphin", "tsType": "string"}, "mascot": {"editor": "text", "default": "dolphin-mascot.png", "tsType": "string"}, "linkHref": {"editor": "text", "default": "./Mein%20Zimmer.dc.html", "tsType": "string"}, "linkLabel": {"editor": "text", "default": "💡 Mein Zimmer", "tsType": "string"}, "actions": {"editor": null, "tsType": "Array<{label: string, style: string, onClick: () => void}>"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
248
claude-design/Delphin Uhr.dc.html
Normal file
248
claude-design/Delphin Uhr.dc.html
Normal file
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes bubbleUp { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-115vh) scale(1.3); opacity: 0; } }
|
||||
@keyframes dolphinSwim {
|
||||
0% { transform: translate(-35vmin, 0) scaleX(1); }
|
||||
46% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(1); }
|
||||
50% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(-1); }
|
||||
96% { transform: translate(-35vmin, 22vh) scaleX(-1); }
|
||||
100% { transform: translate(-35vmin, 0) scaleX(1); }
|
||||
}
|
||||
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-3vmin) rotate(5deg); } }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; font-family: 'Nunito', system-ui, sans-serif; }
|
||||
a { color: oklch(70% 0.16 340); }
|
||||
a:hover { color: oklch(55% 0.16 340); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;width:100vw;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:6vmin;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||
|
||||
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:0s;"></div>
|
||||
<div style="position:absolute;left:12%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 12s linear infinite;animation-delay:-3s;"></div>
|
||||
<div style="position:absolute;left:22%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 8s linear infinite;animation-delay:-6s;"></div>
|
||||
<div style="position:absolute;left:48%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 11s linear infinite;animation-delay:-2s;"></div>
|
||||
<div style="position:absolute;left:63%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 10s linear infinite;animation-delay:-7s;"></div>
|
||||
<div style="position:absolute;left:78%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 14s linear infinite;animation-delay:-5s;"></div>
|
||||
<div style="position:absolute;left:88%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:-1s;"></div>
|
||||
<div style="position:absolute;left:95%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 13s linear infinite;animation-delay:-9s;"></div>
|
||||
|
||||
<div style="position:absolute;left:0;top:30%;width:min(30vmin, 260px);pointer-events:none;animation:dolphinSwim 46s ease-in-out infinite;">
|
||||
<img src="dolphin-watch.png" alt="Delphin" style="width:100%;height:auto;display:block;filter:drop-shadow(0 2vmin 3vmin oklch(15% 0.05 210 / .45));animation:dolphinBob 3.2s ease-in-out infinite;">
|
||||
</div>
|
||||
|
||||
<!-- watch -->
|
||||
<div style="position:relative;width:78vmin;height:96vmin;flex:none;filter:drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / .5));">
|
||||
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(70% 0.16 340) 0%, oklch(70% 0.16 340) 70%, oklch(from oklch(70% 0.16 340) calc(l - .08) c h) 100%);left:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(-8deg);transform-origin:50% 100%;"></div>
|
||||
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(66% 0.12 205) 0%, oklch(66% 0.12 205) 70%, oklch(from oklch(66% 0.12 205) calc(l - .08) c h) 100%);right:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(8deg);transform-origin:50% 100%;"></div>
|
||||
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(64% 0.11 175) 0%, oklch(64% 0.11 175) 70%, oklch(from oklch(64% 0.11 175) calc(l - .08) c h) 100%);left:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(8deg);transform-origin:50% 0%;"></div>
|
||||
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(58% 0.12 245) 0%, oklch(58% 0.12 245) 70%, oklch(from oklch(58% 0.12 245) calc(l - .08) c h) 100%);right:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(-8deg);transform-origin:50% 0%;"></div>
|
||||
|
||||
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(70% 0.16 340);transform:rotate(60deg) translateY(-38vmin);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(66% 0.12 205);transform:rotate(118deg) translateY(-38vmin);"></div>
|
||||
|
||||
<div style="position:absolute;left:0;top:9vmin;width:78vmin;height:78vmin;border-radius:50%;box-sizing:border-box;border:.6vmin solid oklch(28% 0.04 220);background:radial-gradient(circle closest-side, transparent 88.6%, oklch(28% 0.04 220) 88.6%, oklch(28% 0.04 220) 89.6%, transparent 89.6%), conic-gradient(from 210deg, oklch(94% 0.008 210), oklch(81% 0.015 210), oklch(96% 0.006 210), oklch(79% 0.015 210), oklch(94% 0.008 210));">
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-36.8vmin) rotate(0deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(7.5deg) translateY(-36.8vmin) rotate(-7.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(15deg) translateY(-36.8vmin) rotate(-15deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(22.5deg) translateY(-36.8vmin) rotate(-22.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-36.8vmin) rotate(-30deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(37.5deg) translateY(-36.8vmin) rotate(-37.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg) translateY(-36.8vmin) rotate(-45deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(52.5deg) translateY(-36.8vmin) rotate(-52.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-36.8vmin) rotate(-60deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(67.5deg) translateY(-36.8vmin) rotate(-67.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(75deg) translateY(-36.8vmin) rotate(-75deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(82.5deg) translateY(-36.8vmin) rotate(-82.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-36.8vmin) rotate(-90deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(97.5deg) translateY(-36.8vmin) rotate(-97.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(105deg) translateY(-36.8vmin) rotate(-105deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(112.5deg) translateY(-36.8vmin) rotate(-112.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-36.8vmin) rotate(-120deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(127.5deg) translateY(-36.8vmin) rotate(-127.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(135deg) translateY(-36.8vmin) rotate(-135deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(142.5deg) translateY(-36.8vmin) rotate(-142.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-36.8vmin) rotate(-150deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(157.5deg) translateY(-36.8vmin) rotate(-157.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(165deg) translateY(-36.8vmin) rotate(-165deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(172.5deg) translateY(-36.8vmin) rotate(-172.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-36.8vmin) rotate(-180deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(187.5deg) translateY(-36.8vmin) rotate(-187.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(195deg) translateY(-36.8vmin) rotate(-195deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(202.5deg) translateY(-36.8vmin) rotate(-202.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-36.8vmin) rotate(-210deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(217.5deg) translateY(-36.8vmin) rotate(-217.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(225deg) translateY(-36.8vmin) rotate(-225deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(232.5deg) translateY(-36.8vmin) rotate(-232.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-36.8vmin) rotate(-240deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(247.5deg) translateY(-36.8vmin) rotate(-247.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(255deg) translateY(-36.8vmin) rotate(-255deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(262.5deg) translateY(-36.8vmin) rotate(-262.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-36.8vmin) rotate(-270deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(277.5deg) translateY(-36.8vmin) rotate(-277.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(285deg) translateY(-36.8vmin) rotate(-285deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(292.5deg) translateY(-36.8vmin) rotate(-292.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-36.8vmin) rotate(-300deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(307.5deg) translateY(-36.8vmin) rotate(-307.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(315deg) translateY(-36.8vmin) rotate(-315deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(322.5deg) translateY(-36.8vmin) rotate(-322.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-36.8vmin) rotate(-330deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(337.5deg) translateY(-36.8vmin) rotate(-337.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(345deg) translateY(-36.8vmin) rotate(-345deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(352.5deg) translateY(-36.8vmin) rotate(-352.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||
|
||||
<div style="position:absolute;left:50%;top:50%;width:68.4vmin;height:68.4vmin;margin:-34.2vmin 0 0 -34.2vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:inset 0 .6vmin 1.4vmin oklch(30% 0.03 220 / .18);"></div>
|
||||
|
||||
<!-- hour sections -->
|
||||
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(91% 0.06 205) 0deg 30deg, oklch(91% 0.06 175) 30deg 60deg, oklch(91% 0.06 245) 60deg 90deg, oklch(91% 0.07 345) 90deg 120deg, oklch(91% 0.06 205) 120deg 150deg, oklch(91% 0.06 175) 150deg 180deg, oklch(91% 0.06 245) 180deg 210deg, oklch(91% 0.07 345) 210deg 240deg, oklch(91% 0.06 205) 240deg 270deg, oklch(91% 0.06 175) 270deg 300deg, oklch(91% 0.06 245) 300deg 330deg, oklch(91% 0.07 345) 330deg 360deg);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:repeating-conic-gradient(from 14.6deg, oklch(99% 0.005 210) 0deg .8deg, transparent .8deg 30deg);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;box-sizing:border-box;border:.4vmin solid oklch(28% 0.04 220);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:20vmin;height:20vmin;margin:-10vmin 0 0 -10vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:0 0 0 .4vmin oklch(28% 0.04 220 / .25);"></div>
|
||||
|
||||
<!-- minute track -->
|
||||
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(80% 0.10 205) 0deg 30deg, oklch(80% 0.10 175) 30deg 60deg, oklch(80% 0.10 245) 60deg 90deg, oklch(80% 0.13 345) 90deg 120deg, oklch(80% 0.10 205) 120deg 150deg, oklch(80% 0.10 175) 150deg 180deg, oklch(80% 0.10 245) 180deg 210deg, oklch(80% 0.13 345) 210deg 240deg, oklch(80% 0.10 205) 240deg 270deg, oklch(80% 0.10 175) 270deg 300deg, oklch(80% 0.10 245) 300deg 330deg, oklch(80% 0.13 345) 330deg 360deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -.5deg, oklch(28% 0.04 220) 0deg 1deg, transparent 1deg 6deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);opacity:.7;"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -1.3deg, oklch(28% 0.04 220) 0deg 2.6deg, transparent 2.6deg 30deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);"></div>
|
||||
|
||||
<sc-if value="{{ showMinutes }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:2.3vmin;line-height:1;color:oklch(28% 0.04 220);">
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-29.5vmin) rotate(0deg);">60</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-29.5vmin) rotate(-30deg);">5</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-29.5vmin) rotate(-60deg);">10</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-29.5vmin) rotate(-90deg);">15</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-29.5vmin) rotate(-120deg);">20</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-29.5vmin) rotate(-150deg);">25</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-29.5vmin) rotate(-180deg);">30</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-29.5vmin) rotate(-210deg);">35</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-29.5vmin) rotate(-240deg);">40</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-29.5vmin) rotate(-270deg);">45</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-29.5vmin) rotate(-300deg);">50</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-29.5vmin) rotate(-330deg);">55</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:8.2vmin;line-height:1;letter-spacing:-.3vmin;-webkit-text-stroke:.45vmin oklch(28% 0.04 220);paint-order:stroke fill;">
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-22.4vmin) rotate(-30deg);color:oklch(56% 0.13 175);">1</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-22.4vmin) rotate(-60deg);color:oklch(56% 0.13 245);">2</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-22.4vmin) rotate(-90deg);color:oklch(60% 0.19 345);">3</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-22.4vmin) rotate(-120deg);color:oklch(56% 0.13 205);">4</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-22.4vmin) rotate(-150deg);color:oklch(56% 0.13 175);">5</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-22.4vmin) rotate(-180deg);color:oklch(56% 0.13 245);">6</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-22.4vmin) rotate(-210deg);color:oklch(60% 0.19 345);">7</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-22.4vmin) rotate(-240deg);color:oklch(56% 0.13 205);">8</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-22.4vmin) rotate(-270deg);color:oklch(56% 0.13 175);">9</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-22.4vmin) rotate(-300deg);color:oklch(56% 0.13 245);">10</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-22.4vmin) rotate(-330deg);color:oklch(60% 0.19 345);">11</div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(360deg) translateY(-22.4vmin) rotate(-360deg);color:oklch(56% 0.13 205);">12</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;left:50%;bottom:50%;width:5.4vmin;height:15vmin;margin-left:-2.7vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ hourStyle }}">
|
||||
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.06 345);clip-path:polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);"></div>
|
||||
</div>
|
||||
<div style="position:absolute;left:50%;bottom:50%;width:5vmin;height:24vmin;margin-left:-2.5vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ minuteStyle }}">
|
||||
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.05 200);clip-path:polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);"></div>
|
||||
</div>
|
||||
<sc-if value="{{ showSeconds }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:absolute;left:50%;bottom:50%;width:.7vmin;height:27vmin;margin-left:-.35vmin;transform-origin:50% 100%;{{ secondStyle }}">
|
||||
<div style="position:absolute;inset:0 0 -5vmin 0;border-radius:1vmin;background:oklch(64% 0.19 345);"></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="position:absolute;left:50%;top:50%;width:4.2vmin;height:4.2vmin;margin:-2.1vmin 0 0 -2.1vmin;border-radius:50%;background:oklch(80% 0.10 205);box-shadow:0 0 0 .5vmin oklch(28% 0.04 220);"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;width:1.4vmin;height:1.4vmin;margin:-.7vmin 0 0 -.7vmin;border-radius:50%;background:oklch(28% 0.04 220);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- countdowns -->
|
||||
<sc-if value="{{ hasCountdowns }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:relative;flex:0 1 74vmin;min-width:0;display:flex;flex-direction:column;gap:3vmin;">
|
||||
<sc-for list="{{ countdowns }}" as="cd" hint-placeholder-count="2">
|
||||
<div style="display:flex;align-items:center;gap:3.5vmin;padding:3vmin 4vmin 3vmin 3vmin;border-radius:4.5vmin;background:oklch(97% 0.01 210 / .68);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 2vmin 5vmin oklch(15% 0.05 210 / .35);">
|
||||
<div style="width:24vmin;height:18vmin;flex:none;display:flex;align-items:center;justify-content:center;">
|
||||
<img src="{{ cd.image }}" alt="{{ cd.label }}" style="max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / .3));">
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:.6vmin;">
|
||||
<div style="font-size:15vmin;font-weight:900;line-height:.95;font-variant-numeric:tabular-nums;letter-spacing:-.3vmin;{{ cd.timeStyle }}">{{ cd.remaining }}</div>
|
||||
<div style="height:2vmin;border-radius:999px;background:oklch(30% 0.04 210 / .15);overflow:hidden;margin-top:.8vmin;">
|
||||
<div style="height:100%;border-radius:999px;{{ cd.barStyle }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"showSeconds":{"editor":"boolean","default":true,"tsType":"boolean"},"showMinuteNumbers":{"editor":"boolean","default":true,"tsType":"boolean"},"demoCountdowns":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
||||
// Countdown rules. Each shows from (target - lead) until target on the given weekdays (0 = So … 6 = Sa).
|
||||
const COUNTDOWNS = [
|
||||
{ id: "bus", label: "Bus", image: "bus.png", target: "07:25", lead: 45, days: [1, 2, 3, 4, 5] },
|
||||
{ id: "bett", label: "Schlafenszeit", image: "dolphin-watch.png", target: "19:30", lead: 30, days: [0, 1, 2, 3, 4, 5, 6] },
|
||||
];
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = { now: new Date() };
|
||||
componentDidMount() { this._t = setInterval(() => this.setState({ now: new Date() }), 250); }
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
|
||||
activeCountdowns(d, demo) {
|
||||
const daySec = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
|
||||
const out = [];
|
||||
COUNTDOWNS.forEach((r, i) => {
|
||||
const [th, tm] = r.target.split(":").map(Number);
|
||||
const targetSec = th * 3600 + tm * 60;
|
||||
const leadSec = r.lead * 60;
|
||||
let rem = targetSec - daySec;
|
||||
const active = r.days.includes(d.getDay()) && rem > 0 && rem <= leadSec;
|
||||
if (!active) {
|
||||
if (!demo) return;
|
||||
rem = leadSec - ((daySec + i * 1100) % leadSec);
|
||||
}
|
||||
out.push({ r, rem, frac: rem / leadSec });
|
||||
});
|
||||
return out.sort((a, b) => a.rem - b.rem);
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const d = this.state.now;
|
||||
const day = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
|
||||
const rot = (deg) => `transform:rotate(${deg}deg);`;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const list = this.activeCountdowns(d, this.props.demoCountdowns ?? true).map(({ r, rem, frac }) => {
|
||||
const mins = Math.ceil(rem / 60);
|
||||
const soon = mins <= 10;
|
||||
return {
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
image: r.image,
|
||||
target: r.target.replace(/^0/, ""),
|
||||
remaining: `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`,
|
||||
timeStyle: `color:${soon ? "oklch(58% 0.19 345)" : "oklch(30% 0.05 210)"};`,
|
||||
barStyle: `width:${(frac * 100).toFixed(2)}%;background:${soon ? "oklch(70% 0.16 340)" : "oklch(66% 0.12 205)"};transition:width .25s linear;`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
hourStyle: rot(day / 120),
|
||||
minuteStyle: rot(day / 10),
|
||||
secondStyle: rot(day * 6),
|
||||
showSeconds: this.props.showSeconds ?? true,
|
||||
showMinutes: this.props.showMinuteNumbers ?? true,
|
||||
countdowns: list,
|
||||
hasCountdowns: list.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
920
claude-design/Dolphin Beats.dc.html
Normal file
920
claude-design/Dolphin Beats.dc.html
Normal file
@@ -0,0 +1,920 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
|
||||
@keyframes dolphinSwim {
|
||||
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||
}
|
||||
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||
a { color: oklch(70% 0.16 340); }
|
||||
a:hover { color: oklch(55% 0.16 340); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;width:100%;height:100vh;overflow:hidden;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||
|
||||
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9s linear infinite;animation-delay:0s;"></div>
|
||||
<div style="position:absolute;left:15%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .4);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||
<div style="position:absolute;left:28%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||
<div style="position:absolute;left:42%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 10s linear infinite;animation-delay:4s;"></div>
|
||||
<div style="position:absolute;left:58%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8s linear infinite;animation-delay:3s;"></div>
|
||||
<div style="position:absolute;left:70%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(90% 0.02 210 / .35);animation:bubbleRise 13s linear infinite;animation-delay:5s;"></div>
|
||||
<div style="position:absolute;left:82%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9.5s linear infinite;animation-delay:1.5s;"></div>
|
||||
<div style="position:absolute;left:92%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.5s linear infinite;animation-delay:2.5s;"></div>
|
||||
<div style="position:absolute;left:2%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 7.5s linear infinite;animation-delay:3.5s;"></div>
|
||||
<div style="position:absolute;left:9%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 14s linear infinite;animation-delay:6s;"></div>
|
||||
<div style="position:absolute;left:21%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8.5s linear infinite;animation-delay:5.5s;"></div>
|
||||
<div style="position:absolute;left:34%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .32);animation:bubbleRise 12.5s linear infinite;animation-delay:1.2s;"></div>
|
||||
<div style="position:absolute;left:38%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:4.6s;"></div>
|
||||
<div style="position:absolute;left:48%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:0.6s;"></div>
|
||||
<div style="position:absolute;left:53%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:7s;"></div>
|
||||
<div style="position:absolute;left:63%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7.2s linear infinite;animation-delay:2.2s;"></div>
|
||||
<div style="position:absolute;left:66%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .38);animation:bubbleRise 11.5s linear infinite;animation-delay:5.2s;"></div>
|
||||
<div style="position:absolute;left:76%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(90% 0.02 210 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:6.4s;"></div>
|
||||
<div style="position:absolute;left:87%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .28);animation:bubbleRise 15s linear infinite;animation-delay:3.2s;"></div>
|
||||
<div style="position:absolute;left:96%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.8s linear infinite;animation-delay:8s;"></div>
|
||||
<div style="position:absolute;left:45%;bottom:-40px;width:7px;height:7px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.2s linear infinite;animation-delay:7.6s;"></div>
|
||||
|
||||
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:relative;z-index:1;height:100%;display:flex;flex-direction:column;">
|
||||
|
||||
<!-- header -->
|
||||
<dc-import name="AppHeader" title="Musik Delphin" mascot="dolphin-mascot.png" link-href="./Mein%20Zimmer.dc.html" link-label="💡 Mein Zimmer" hint-size="100%,116px"></dc-import>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:4px 32px 6px;">
|
||||
<button onClick="{{ setFilterAll }}" style="{{ filterAllStyle }}">Alles</button>
|
||||
<button onClick="{{ setFilterMusic }}" style="{{ filterMusicStyle }}">🎵 Musik</button>
|
||||
<button onClick="{{ setFilterBook }}" style="{{ filterBookStyle }}">📖 Hörbücher</button>
|
||||
</div>
|
||||
|
||||
<!-- search pill -->
|
||||
<sc-if value="{{ showSearchBar }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin:2px 32px 6px;display:flex;align-items:center;justify-content:center;gap:12px;">
|
||||
<div style="background:oklch(97% 0.01 210);color:oklch(30% 0.04 210);font-weight:800;font-size:20px;padding:10px 20px;border-radius:999px;box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:14px;font-weight:800;background:oklch(30% 0.04 210);color:#fff;padding:4px 10px;border-radius:999px;">{{ modeLabel }}</span>
|
||||
<span>{{ searchDisplay }}</span>
|
||||
</div>
|
||||
<div style="color:oklch(90% 0.02 210 / .85);font-weight:700;font-size:14px;">{{ resultCountLabel }}</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- results -->
|
||||
<div ref="{{ scrollerRef }}" style="flex:1;overflow:auto;min-height:0;padding:14px 32px 250px;">
|
||||
|
||||
<!-- song hits -->
|
||||
<sc-if value="{{ hasSongHits }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-bottom:30px;">
|
||||
<div style="display:flex;align-items:baseline;justify-content:center;gap:10px;margin-bottom:12px;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);">Titel</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(88% 0.02 210 / .7);">{{ songHitLabel }}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;max-width:760px;margin:0 auto;">
|
||||
<sc-for list="{{ songHits }}" as="song" hint-placeholder-count="3">
|
||||
<div onClick="{{ playSongHit }}" data-nav-index="{{ song.navIndex }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||
<div style="{{ song.chipStyle }}"></div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ song.title }}</div>
|
||||
<div style="font-size:12px;font-weight:700;color:oklch(88% 0.02 210 / .65);">{{ song.albumLine }}</div>
|
||||
</div>
|
||||
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(88% 0.02 210 / .6);">{{ song.duration }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- category grid -->
|
||||
<sc-if value="{{ hasCategories }}" hint-placeholder-val="{{ true }}">
|
||||
<div>
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ categorySectionLabel }}</div>
|
||||
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;">
|
||||
<sc-for list="{{ categories }}" as="cat" hint-placeholder-count="8">
|
||||
<div onClick="{{ openCategory }}" data-nav-index="{{ cat.navIndex }}" data-key="{{ cat.key }}" style="{{ cat.cardStyle }}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;aspect-ratio:1;">
|
||||
<sc-for list="{{ cat.tiles }}" as="tile" hint-placeholder-count="4">
|
||||
<div style="{{ tile.style }}"></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="padding:4px 12px 14px;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ cat.name }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:5px;">{{ cat.countLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- album grid -->
|
||||
<sc-if value="{{ hasAlbumHits }}" hint-placeholder-val="{{ false }}">
|
||||
<div>
|
||||
<sc-if value="{{ hasCategoryCrumb }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:14px;">
|
||||
<button onClick="{{ clearCategory }}" style="border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 16px;border-radius:999px;background:oklch(97% 0.01 210 / .95);color:oklch(30% 0.04 210);">← Alle</button>
|
||||
<div style="font-size:22px;font-weight:900;color:oklch(97% 0.01 210);">{{ categoryCrumb }}</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ hasSearch }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ albumSectionLabel }}</div>
|
||||
</sc-if>
|
||||
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;justify-content:center;">
|
||||
<sc-for list="{{ filteredAlbums }}" as="album" hint-placeholder-count="8">
|
||||
<div onClick="{{ openAlbumCard }}" data-nav-index="{{ album.navIndex }}" data-id="{{ album.id }}" style="{{ album.cardStyle }}">
|
||||
<div style="{{ album.coverStyle }}">
|
||||
<span style="{{ album.coverLabelStyle }}">{{ album.coverLabel }}</span>
|
||||
</div>
|
||||
<div style="{{ album.bodyStyle }}">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ album.title }}</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(30% 0.03 210);margin-top:2px;">{{ album.artist }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:6px;">{{ album.songCountLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasNoResults }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="text-align:center;margin-top:60px;color:oklch(92% 0.02 210 / .8);">
|
||||
<img src="dolphin-mascot.png" alt="" style="width:120px;height:120px;object-fit:contain;opacity:.9;">
|
||||
<div style="font-size:22px;font-weight:800;margin-top:10px;">Nichts gefunden — probier andere Buchstaben!</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- play view -->
|
||||
<sc-if value="{{ isPlay }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:relative;z-index:1;height:100%;min-height:0;overflow:auto;">
|
||||
<div style="min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px, 1.8vh, 26px);padding:clamp(14px, 3.5vh, 40px) 40px;">
|
||||
|
||||
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="position:absolute;top:14%;left:0;width:140px;height:140px;object-fit:contain;z-index:0;pointer-events:none;opacity:.92;animation:dolphinSwim 62s linear infinite;filter:drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45));">
|
||||
|
||||
<div style="{{ bigCoverStyle }}">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;color:oklch(98% 0 0 / .85);letter-spacing:1px;">ALBUM COVER</span>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;max-width:760px;position:relative;z-index:1;flex:none;">
|
||||
<div style="font-size:clamp(28px, 4.6vh, 46px);font-weight:900;color:oklch(97% 0.01 210);line-height:1.1;text-wrap:pretty;">{{ currentSongTitle }}</div>
|
||||
<div style="font-size:20px;font-weight:700;color:oklch(88% 0.02 210 / .8);margin-top:8px;">{{ currentAlbumLine }}</div>
|
||||
<div style="font-size:15px;font-weight:800;color:oklch(78% 0.14 340);margin-top:6px;">{{ trackCounter }}</div>
|
||||
</div>
|
||||
|
||||
<div style="width:min(680px, 90%);position:relative;z-index:1;">
|
||||
<div style="height:14px;border-radius:999px;background:oklch(30% 0.03 210 / .55);overflow:hidden;">
|
||||
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px;font-size:14px;font-weight:800;color:oklch(88% 0.02 210 / .8);">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;">{{ trackRemainingLabel }}</span>
|
||||
<span>{{ albumRemainingLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:22px;position:relative;z-index:1;flex:none;">
|
||||
<button onClick="{{ prevTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span><span style="width:22px;height:26px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||
<button onClick="{{ togglePlay }}" style="width:clamp(68px, 12vh, 110px);height:clamp(68px, 12vh, 110px);border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 30px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:9px;"><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:clamp(24px, 4.2vh, 38px);height:clamp(26px, 4.6vh, 42px);background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||
<button onClick="{{ nextTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:22px;height:26px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span></span></button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
|
||||
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:24px;line-height:1;">🔊</button>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:18px;height:clamp(26px, 4.6vh, 44px);border-radius:5px;{{ bar.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick="{{ goBrowse }}" style="position:absolute;top:28px;left:32px;border:none;cursor:pointer;background:oklch(97% 0.01 210 / .95);border-radius:999px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;color:oklch(30% 0.04 210);display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px oklch(15% 0.05 210 / .4);">
|
||||
<span style="font:800 13px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:2px 8px;border-radius:6px;">/</span>
|
||||
Zurück zur Suche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- album detail panel -->
|
||||
<sc-if value="{{ hasOpenAlbum }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ closeAlbum }}" style="position:absolute;inset:0;z-index:4;background:oklch(15% 0.03 210 / .6);display:flex;align-items:center;justify-content:center;padding:40px;">
|
||||
<div onClick="{{ stopClick }}" style="background:oklch(96% 0.012 210);border-radius:26px;padding:26px;width:640px;max-width:100%;max-height:100%;overflow:auto;box-shadow:0 24px 60px oklch(10% 0.04 210 / .55);">
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;">
|
||||
<div style="{{ openAlbumCoverStyle }}">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);">ALBUM COVER</span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:26px;font-weight:900;color:oklch(22% 0.03 210);line-height:1.15;">{{ openAlbumTitle }}</div>
|
||||
<div style="font-size:15px;font-weight:700;color:oklch(45% 0.03 210 / .85);margin-top:2px;">{{ openAlbumArtist }}</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(50% 0.03 210 / .7);margin-top:2px;">{{ openAlbumMeta }}</div>
|
||||
<button onClick="{{ playOpenAlbum }}" style="margin-top:14px;border:none;cursor:pointer;background:oklch(70% 0.16 340);color:#fff;font-family:inherit;font-size:16px;font-weight:800;padding:12px 22px;border-radius:999px;display:flex;align-items:center;gap:10px;box-shadow:0 4px 14px oklch(70% 0.16 340 / .45);">
|
||||
<span style="width:14px;height:16px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span>
|
||||
Alle Songs abspielen
|
||||
</button>
|
||||
</div>
|
||||
<button onClick="{{ closeAlbum }}" style="border:none;cursor:pointer;background:oklch(88% 0.02 210);color:oklch(30% 0.04 210);font-family:inherit;font-size:18px;font-weight:900;width:40px;height:40px;border-radius:999px;flex:none;">×</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:22px;">
|
||||
<sc-for list="{{ openAlbumSongs }}" as="song" hint-placeholder-count="5">
|
||||
<div onClick="{{ playSongHit }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||
<div style="{{ song.numStyle }}">{{ song.number }}</div>
|
||||
<div style="flex:1;min-width:0;font-size:16px;font-weight:800;{{ song.titleStyle }}">{{ song.title }}</div>
|
||||
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(45% 0.03 210 / .7);">{{ song.duration }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- shortcut help toggle + overlay -->
|
||||
<button onClick="{{ toggleHelp }}" title="Zaubertasten (F1)" style="position:absolute;top:18px;right:24px;z-index:3;width:44px;height:44px;background:oklch(97% 0.01 210 / .95);border:none;border-radius:999px;box-shadow:0 6px 18px oklch(15% 0.05 210 / .4);font-size:20px;font-weight:900;color:oklch(30% 0.04 210);cursor:pointer;display:flex;align-items:center;justify-content:center;">?</button>
|
||||
|
||||
<sc-if value="{{ showHelp }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ toggleHelp }}" style="position:absolute;inset:0;background:oklch(15% 0.03 210 / .55);z-index:5;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="background:oklch(97% 0.01 210);border-radius:24px;padding:26px 30px;width:300px;box-shadow:0 20px 50px oklch(10% 0.04 210 / .5);">
|
||||
<div style="font-size:17px;font-weight:800;color:oklch(30% 0.04 210);margin-bottom:14px;letter-spacing:.3px;">⌨️ Zaubertasten</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">LEER</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Play / Pause</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="display:flex;gap:4px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+H</div>
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+L</div>
|
||||
</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Song zurück / vor</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="display:flex;gap:4px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+J</div>
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+K</div>
|
||||
</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Leiser / lauter</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">A-Z</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Album oder Hörbuch suchen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">?</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Einzelne Titel suchen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">F1</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Diese Hilfe</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">TAB</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Musik / Hörbücher / alles</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">← ↑ ↓ →</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl bewegen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ENTER</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl abspielen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ESC</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Schließen / Suche löschen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;font-size:12px;font-weight:600;color:oklch(50% 0.03 210 / .8);text-align:center;">Tippe irgendwohin, um zu schließen</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- player bar -->
|
||||
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:absolute;left:0;right:0;bottom:0;z-index:2;background:oklch(18% 0.05 210 / .96);backdrop-filter:blur(6px);padding:16px clamp(12px, 3vw, 32px);box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:clamp(10px, 1.6vw, 22px);border-top:3px solid oklch(70% 0.16 340 / .5);">
|
||||
|
||||
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="width:clamp(56px, 7vw, 84px);height:clamp(56px, 7vw, 84px);object-fit:contain;flex:0 1 auto;min-width:0;animation:{{ dolphinBobAnim }};filter:drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5));">
|
||||
|
||||
<div style="width:52px;height:52px;border-radius:12px;flex:none;{{ playerCoverStyle }}"></div>
|
||||
|
||||
<div style="flex:0 1 220px;min-width:0;overflow:hidden;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ currentSongTitle }}</div>
|
||||
<div style="font-size:13px;font-weight:600;color:oklch(85% 0.02 210 / .75);">{{ currentAlbumLine }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(75% 0.14 340);margin-top:1px;">{{ trackCounter }}</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1 1 auto;max-width:360px;min-width:90px;">
|
||||
<div style="height:10px;border-radius:999px;background:oklch(30% 0.03 210 / .6);overflow:hidden;">
|
||||
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<button onClick="{{ prevTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span><span style="width:14px;height:16px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||
<button onClick="{{ togglePlay }}" style="width:60px;height:60px;border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:5px;"><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:22px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||
<button onClick="{{ nextTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:14px;height:16px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span></span></button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
|
||||
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:18px;line-height:1;">🔊</button>
|
||||
<div style="display:flex;gap:3px;">
|
||||
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:11px;height:30px;border-radius:4px;{{ bar.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 800}}">
|
||||
const ALBUMS = [
|
||||
{ id: 1, title: "Ocean Adventure", artist: "The Coral Crew", hue: 180, songs: [["Set Sail", "2:41"], ["Wave Rider", "3:05"], ["Lighthouse", "2:28"], ["Salty Wind", "3:22"], ["Safe Harbour", "2:55"]] },
|
||||
{ id: 2, title: "Splash Party", artist: "Wavy Tunes", hue: 200, songs: [["Cannonball", "2:12"], ["Pool Noodle Boogie", "2:47"], ["Big Splash", "3:01"], ["Towel Dance", "2:33"]] },
|
||||
{ id: 3, title: "Deep Blue Dreams", artist: "Finn & Friends", hue: 220, songs: [["Sinking Slow", "3:48"], ["Blue Lullaby", "4:12"], ["Silent Depths", "3:30"], ["Bioluminescence", "4:02"], ["Morning Surface", "3:14"]] },
|
||||
{ id: 4, title: "Bubble Trouble", artist: "The Coral Crew", hue: 250, songs: [["Bubble Machine", "2:20"], ["Pop Pop Pop", "1:58"], ["Fizzy Water", "2:44"], ["Upside Down", "3:06"]] },
|
||||
{ id: 5, title: "Starfish Disco", artist: "Reef Riders", hue: 20, songs: [["Five Arms Up", "3:18"], ["Glitter Sand", "2:52"], ["Disco Tide", "3:40"], ["Neon Anemone", "3:02"], ["Last Dance", "4:05"]] },
|
||||
{ id: 6, title: "Whale Song", artist: "Finn & Friends", hue: 240, songs: [["Long Call", "5:12"], ["Migration", "4:36"], ["Under Ice", "3:58"], ["Deep Echo", "4:20"]] },
|
||||
{ id: 7, title: "Tidal Groove", artist: "Wavy Tunes", hue: 195, songs: [["High Tide", "3:11"], ["Low Tide", "2:49"], ["Moon Pull", "3:35"], ["Rock Pool", "2:58"], ["Undertow", "3:24"]] },
|
||||
{ id: 8, title: "Coral Reef Jam", artist: "Reef Riders", hue: 15, songs: [["Clownfish Shuffle", "2:36"], ["Parrotfish Parade", "3:08"], ["Coral Bloom", "3:44"], ["Reef Party", "2:50"]] },
|
||||
{ id: 9, title: "Sandy Shores", artist: "Beach Buddies", hue: 40, songs: [["Barefoot", "2:26"], ["Sandcastle", "3:02"], ["Seagull Song", "2:14"], ["Sunset Picnic", "3:30"], ["Beach Fire", "3:52"]] },
|
||||
{ id: 10, title: "Moonlit Lagoon", artist: "Finn & Friends", hue: 260, songs: [["Silver Water", "4:08"], ["Night Swim", "3:36"], ["Sleepy Palms", "3:12"], ["Star Reflection", "4:24"]] },
|
||||
{ id: 11, title: "Jellyfish Jive", artist: "The Coral Crew", hue: 300, songs: [["Wobble Wobble", "2:18"], ["Glow Parade", "2:56"], ["Tentacle Twist", "3:14"], ["Drifting", "3:40"], ["Sting Free", "2:44"]] },
|
||||
{ id: 12, title: "Seahorse Shuffle", artist: "Beach Buddies", hue: 30, songs: [["Tiny Trot", "2:22"], ["Seagrass Swing", "2:58"], ["Curly Tail", "3:16"], ["Slow Parade", "3:04"]] },
|
||||
{ id: 13, title: "Current Rhythm", artist: "Wavy Tunes", hue: 205, songs: [["Fast Current", "3:26"], ["Drift Beat", "3:02"], ["Whirlpool", "3:48"], ["Cross Stream", "2:52"], ["Open Water", "4:10"]] },
|
||||
{ id: 14, title: "Anchor Down", artist: "Reef Riders", hue: 10, songs: [["Chain Rattle", "2:40"], ["Harbour Lights", "3:20"], ["Old Rope", "3:04"], ["Anchor Down", "3:52"]] },
|
||||
{ id: 15, type: "book", series: "Wal Wilma", title: "Der kleine Wal Wilma", artist: "Erzählt von Lena Fisch", hue: 230, songs: [["Kapitel 1 — Wilma wacht auf", "8:12"], ["Kapitel 2 — Die große Welle", "9:04"], ["Kapitel 3 — Ein Freund im Dunkeln", "7:48"], ["Kapitel 4 — Heimweg", "8:36"]] },
|
||||
{ id: 16, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe und der Schatz", artist: "Erzählt von Jonas Meer", hue: 25, songs: [["Kapitel 1 — Die alte Karte", "10:20"], ["Kapitel 2 — Sturm vor Kap Kork", "11:02"], ["Kapitel 3 — Die Höhle", "9:44"], ["Kapitel 4 — Gold und Muscheln", "10:12"], ["Kapitel 5 — Zurück im Hafen", "8:58"]] },
|
||||
{ id: 17, type: "book", series: "Nele", title: "Nele taucht ab", artist: "Erzählt von Mira Sand", hue: 190, songs: [["Kapitel 1 — Die neue Taucherbrille", "7:30"], ["Kapitel 2 — Unten am Riff", "8:14"], ["Kapitel 3 — Das leise Lied", "9:06"], ["Kapitel 4 — Wieder oben", "6:52"]] },
|
||||
{ id: 18, type: "book", series: "Muschelbande", title: "Die Muschelbande", artist: "Erzählt von Tom Anker", hue: 300, songs: [["Kapitel 1 — Drei Freunde", "8:48"], ["Kapitel 2 — Der Plan", "9:22"], ["Kapitel 3 — Verfolgt", "10:04"], ["Kapitel 4 — Das Versteck", "8:16"], ["Kapitel 5 — Alles gut", "7:40"]] },
|
||||
{ id: 19, type: "book", series: "Riff-Geschichten", title: "Gute-Nacht-Geschichten vom Riff", artist: "Erzählt von Lena Fisch", hue: 265, songs: [["Der schläfrige Seestern", "6:40"], ["Wo die Quallen leuchten", "7:12"], ["Das müde Seepferdchen", "6:28"], ["Lied der Strömung", "8:02"]] },
|
||||
{ id: 20, type: "book", series: "Wal Wilma", title: "Wal Wilma im Eismeer", artist: "Erzählt von Lena Fisch", hue: 245, songs: [["Kapitel 1 — Kaltes Wasser", "9:10"], ["Kapitel 2 — Das Eisloch", "8:26"], ["Kapitel 3 — Der Pinguin", "9:44"], ["Kapitel 4 — Nach Hause", "8:02"]] },
|
||||
{ id: 21, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe segelt los", artist: "Erzählt von Jonas Meer", hue: 35, songs: [["Kapitel 1 — Neue Segel", "9:52"], ["Kapitel 2 — Der blinde Passagier", "10:36"], ["Kapitel 3 — Nebelbank", "9:18"], ["Kapitel 4 — Land in Sicht", "8:44"]] },
|
||||
{ id: 22, type: "book", series: "Nele", title: "Nele und der Seestern", artist: "Erzählt von Mira Sand", hue: 200, songs: [["Kapitel 1 — Fünf Arme", "7:22"], ["Kapitel 2 — Der Gezeitenteich", "8:06"], ["Kapitel 3 — Zurück ins Meer", "7:54"]] },
|
||||
];
|
||||
const TRACK_DURATION_MS = 24000;
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
category: null,
|
||||
filter: "all",
|
||||
albumId: null,
|
||||
trackIndex: 0,
|
||||
openAlbumId: null,
|
||||
view: "browse",
|
||||
selIndex: 0,
|
||||
cols: 4,
|
||||
isPlaying: false,
|
||||
volume: 70,
|
||||
progress: 0,
|
||||
showHelp: false,
|
||||
};
|
||||
|
||||
playPop(freq) {
|
||||
try {
|
||||
if (!this._ac) this._ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const ac = this._ac;
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, ac.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq * 1.8, ac.currentTime + 0.08);
|
||||
gain.gain.setValueAtTime(0.15, ac.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.15);
|
||||
osc.connect(gain).connect(ac.destination);
|
||||
osc.start();
|
||||
osc.stop(ac.currentTime + 0.16);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._onKey = (e) => this.handleKey(e);
|
||||
window.addEventListener("keydown", this._onKey);
|
||||
this._onResize = () => this.measureCols();
|
||||
window.addEventListener("resize", this._onResize);
|
||||
this.measureCols();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keydown", this._onKey);
|
||||
window.removeEventListener("resize", this._onResize);
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
}
|
||||
|
||||
navCounts() {
|
||||
return { songs: this.songMatches().length, albums: this.albumMatches().length + this.categoryMatches().length };
|
||||
}
|
||||
|
||||
moveSel(dx, dy) {
|
||||
const { songs, albums } = this.navCounts();
|
||||
const total = songs + albums;
|
||||
if (!total) return;
|
||||
const cols = Math.max(1, this.state.cols);
|
||||
this.setState((s) => {
|
||||
let i = Math.min(s.selIndex, total - 1);
|
||||
if (dx) i += dx;
|
||||
if (dy) {
|
||||
if (i < songs) i += dy;
|
||||
else i += dy * cols;
|
||||
}
|
||||
if (i < 0) i = 0;
|
||||
if (i > total - 1) i = total - 1;
|
||||
return { selIndex: i };
|
||||
});
|
||||
}
|
||||
|
||||
playSelection() {
|
||||
const songs = this.songMatches();
|
||||
const albums = this.albumMatches();
|
||||
const cats = this.categoryMatches();
|
||||
const i = Math.min(this.state.selIndex, songs.length + albums.length + cats.length - 1);
|
||||
if (i < 0) return;
|
||||
if (i < songs.length) return this.playSong(songs[i].album.id, songs[i].index);
|
||||
if (cats.length) {
|
||||
const c = cats[i - songs.length];
|
||||
if (c) this.setState({ category: c.key, selIndex: 0 });
|
||||
return;
|
||||
}
|
||||
const a = albums[i - songs.length];
|
||||
if (a) this.playSong(a.id, 0);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const sc = this._scroller;
|
||||
if (!sc) return;
|
||||
if (this._lastSel === this.state.selIndex) return;
|
||||
this._lastSel = this.state.selIndex;
|
||||
const el = sc.querySelector('[data-nav-index="' + this.state.selIndex + '"]');
|
||||
if (!el) return;
|
||||
const top = el.offsetTop - sc.offsetTop;
|
||||
const bottom = top + el.offsetHeight;
|
||||
const pad = 24;
|
||||
if (top - pad < sc.scrollTop) sc.scrollTop = Math.max(0, top - pad);
|
||||
else if (bottom + pad > sc.scrollTop + sc.clientHeight) sc.scrollTop = bottom + pad - sc.clientHeight;
|
||||
}
|
||||
|
||||
measureCols() {
|
||||
const g = this._grid;
|
||||
if (!g) return;
|
||||
const n = getComputedStyle(g).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||
if (n && n !== this.state.cols) this.setState({ cols: n });
|
||||
}
|
||||
|
||||
normalize(s) {
|
||||
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
setFilter(f) {
|
||||
this.playPop(380);
|
||||
this.setState({ filter: f, selIndex: 0, view: "browse", category: null });
|
||||
}
|
||||
|
||||
cycleFilter() {
|
||||
const order = ["all", "music", "book"];
|
||||
this.setFilter(order[(order.indexOf(this.state.filter) + 1) % order.length]);
|
||||
}
|
||||
|
||||
secs(d) {
|
||||
const p = (d || "0:00").split(":");
|
||||
return Number(p[0]) * 60 + Number(p[1]);
|
||||
}
|
||||
|
||||
clock(total) {
|
||||
const t = Math.max(0, Math.round(total));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
remainingSecs(album) {
|
||||
if (!album) return 0;
|
||||
const i = this.state.trackIndex;
|
||||
const cur = this.secs(album.songs[i] ? album.songs[i][1] : "0:00");
|
||||
let rest = cur * (1 - this.state.progress / 100);
|
||||
for (let k = i + 1; k < album.songs.length; k++) rest += this.secs(album.songs[k][1]);
|
||||
return rest;
|
||||
}
|
||||
|
||||
isBook(a) {
|
||||
return a.type === "book";
|
||||
}
|
||||
|
||||
categoryOf(a) {
|
||||
return this.isBook(a) ? a.series || a.artist : a.artist;
|
||||
}
|
||||
|
||||
listMode() {
|
||||
if (this.state.mode === "tracks") return "tracks";
|
||||
if (this.state.search || this.state.category) return "albums";
|
||||
return "categories";
|
||||
}
|
||||
|
||||
categoryMatches() {
|
||||
if (this.listMode() !== "categories") return [];
|
||||
const map = new Map();
|
||||
this.pool().forEach((a) => {
|
||||
const key = this.categoryOf(a);
|
||||
if (!map.has(key)) map.set(key, { key, name: key, albums: [] });
|
||||
map.get(key).albums.push(a);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
navTotal() {
|
||||
return this.songMatches().length + this.albumMatches().length + this.categoryMatches().length;
|
||||
}
|
||||
|
||||
inFilter(a) {
|
||||
const f = this.state.filter;
|
||||
if (f === "book") return this.isBook(a);
|
||||
if (f === "music") return !this.isBook(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
pool() {
|
||||
return ALBUMS.filter((a) => this.inFilter(a));
|
||||
}
|
||||
|
||||
albumMatches() {
|
||||
if (this.state.mode === "tracks") return [];
|
||||
const q = this.normalize(this.state.search);
|
||||
let pool = this.pool();
|
||||
if (!q && !this.state.category) return [];
|
||||
if (this.state.category) pool = pool.filter((a) => this.categoryOf(a) === this.state.category);
|
||||
if (!q) return pool;
|
||||
return pool.filter((a) => this.normalize(a.title + a.artist).includes(q));
|
||||
}
|
||||
|
||||
songMatches() {
|
||||
if (this.state.mode !== "tracks") return [];
|
||||
const q = this.normalize(this.state.search);
|
||||
const hits = [];
|
||||
this.pool().forEach((a) => {
|
||||
a.songs.forEach((s, i) => {
|
||||
if (!q || this.normalize(s[0]).includes(q)) hits.push({ album: a, index: i, title: s[0], duration: s[1] });
|
||||
});
|
||||
});
|
||||
return hits.slice(0, 40);
|
||||
}
|
||||
|
||||
playSong(albumId, index) {
|
||||
this.playPop(300);
|
||||
this.setState({ albumId, trackIndex: index, isPlaying: true, progress: 0, view: "play", openAlbumId: null });
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = setInterval(() => {
|
||||
this.setState((s) => {
|
||||
if (!s.isPlaying) return {};
|
||||
const next = s.progress + 100 / (TRACK_DURATION_MS / 200);
|
||||
if (next >= 100) {
|
||||
this.advance(1, true);
|
||||
return {};
|
||||
}
|
||||
return { progress: next };
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
advance(dir, silent) {
|
||||
if (!silent) this.playPop(260);
|
||||
this.setState((s) => {
|
||||
const album = ALBUMS.find((a) => a.id === s.albumId);
|
||||
if (!album) {
|
||||
const first = this.albumMatches()[0] || ALBUMS[0];
|
||||
return { albumId: first.id, trackIndex: 0, progress: 0, isPlaying: true };
|
||||
}
|
||||
const next = s.trackIndex + dir;
|
||||
if (next >= 0 && next < album.songs.length) {
|
||||
return { trackIndex: next, progress: 0, isPlaying: true };
|
||||
}
|
||||
// step to the neighbouring album and continue there
|
||||
const order = ALBUMS;
|
||||
const ai = order.findIndex((a) => a.id === album.id);
|
||||
const nextAlbum = order[(ai + (dir > 0 ? 1 : -1) + order.length) % order.length];
|
||||
return {
|
||||
albumId: nextAlbum.id,
|
||||
trackIndex: dir > 0 ? 0 : nextAlbum.songs.length - 1,
|
||||
progress: 0,
|
||||
isPlaying: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
handleKey(e) {
|
||||
const key = e.key;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const k = key.toLowerCase();
|
||||
if (k === "l") { e.preventDefault(); this.advance(1); return; }
|
||||
if (k === "h") { e.preventDefault(); this.advance(-1); return; }
|
||||
if (k === "k") { e.preventDefault(); this.setState((s) => ({ volume: Math.min(100, s.volume + 10) })); return; }
|
||||
if (k === "j") { e.preventDefault(); this.setState((s) => ({ volume: Math.max(0, s.volume - 10) })); return; }
|
||||
return;
|
||||
}
|
||||
if (key === "Tab") {
|
||||
e.preventDefault();
|
||||
this.cycleFilter();
|
||||
} else if (key === "/") {
|
||||
e.preventDefault();
|
||||
this.setState({ view: "browse" });
|
||||
} else if (key === " ") {
|
||||
e.preventDefault();
|
||||
this.togglePlayInternal();
|
||||
} else if (key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const browsing = this.state.view === "browse" && this.state.openAlbumId === null && !this.state.showHelp;
|
||||
if (browsing) {
|
||||
if (key === "ArrowRight") this.moveSel(1, 0);
|
||||
else if (key === "ArrowLeft") this.moveSel(-1, 0);
|
||||
else if (key === "ArrowDown") this.moveSel(0, 1);
|
||||
else this.moveSel(0, -1);
|
||||
} else if (key === "ArrowRight") this.advance(1);
|
||||
else if (key === "ArrowLeft") this.advance(-1);
|
||||
else if (key === "ArrowUp") this.setState((s) => ({ volume: Math.min(100, s.volume + 10) }));
|
||||
else this.setState((s) => ({ volume: Math.max(0, s.volume - 10) }));
|
||||
} else if (key === "?") {
|
||||
e.preventDefault();
|
||||
this.playPop(460);
|
||||
this.setState({ mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false });
|
||||
} else if (key === "F1") {
|
||||
e.preventDefault();
|
||||
this.setState((s) => ({ showHelp: !s.showHelp }));
|
||||
} else if (key === "Escape") {
|
||||
this.setState((s) => (s.showHelp || s.openAlbumId !== null
|
||||
? { showHelp: false, openAlbumId: null }
|
||||
: s.search
|
||||
? { search: "", selIndex: 0 }
|
||||
: s.mode === "tracks"
|
||||
? { mode: "albums", selIndex: 0 }
|
||||
: { category: null, selIndex: 0 }));
|
||||
} else if (key === "Backspace") {
|
||||
this.setState((s) => ({ search: s.search.slice(0, -1), selIndex: 0 }));
|
||||
} else if (key === "Enter") {
|
||||
if (this.state.openAlbumId !== null) {
|
||||
this.playSong(this.state.openAlbumId, 0);
|
||||
} else {
|
||||
this.playSelection();
|
||||
}
|
||||
} else if (/^[a-zA-Z0-9]$/.test(key)) {
|
||||
this.setState((s) => ({ search: s.search + key, view: "browse", selIndex: 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
togglePlayInternal() {
|
||||
this.playPop(340);
|
||||
this.setState((s) => {
|
||||
if (s.albumId === null) {
|
||||
const first = this.albumMatches()[0];
|
||||
if (!first) return {};
|
||||
this.startTimer();
|
||||
return { albumId: first.id, trackIndex: 0, isPlaying: true, progress: 0 };
|
||||
}
|
||||
return { isPlaying: !s.isPlaying };
|
||||
});
|
||||
}
|
||||
|
||||
coverBg(a, w) {
|
||||
if (this.isBook(a)) {
|
||||
const h = a.hue;
|
||||
return `background:
|
||||
linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%, oklch(90% 0.03 88) 97.5% 100%),
|
||||
linear-gradient(90deg, transparent 0 3.5%, oklch(92% 0.04 ${h} / .45) 3.5% 4.3%, transparent 4.3% 7%, oklch(92% 0.04 ${h} / .45) 7% 7.8%, transparent 7.8%),
|
||||
linear-gradient(90deg, oklch(30% 0.06 ${h}) 0 11%, oklch(24% 0.05 ${h}) 11% 13%, transparent 13%),
|
||||
linear-gradient(155deg, oklch(64% 0.10 ${h}) 0%, oklch(48% 0.09 ${h}) 100%);`;
|
||||
}
|
||||
return this.stripes(a.hue, w);
|
||||
}
|
||||
|
||||
filterPill(value, active) {
|
||||
return `border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 15px;border-radius:999px;white-space:nowrap;${
|
||||
active
|
||||
? "background:oklch(97% 0.01 210);color:oklch(28% 0.04 210);box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);"
|
||||
: "background:oklch(97% 0.01 210 / .16);color:oklch(97% 0.01 210);"
|
||||
}`;
|
||||
}
|
||||
|
||||
stripes(hue, w) {
|
||||
return `background:repeating-linear-gradient(135deg, oklch(62% 0.12 ${hue}) 0px, oklch(62% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w * 2}px);`;
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const albums = this.albumMatches();
|
||||
const songHits = this.songMatches();
|
||||
const album = ALBUMS.find((a) => a.id === this.state.albumId) || null;
|
||||
const song = album ? album.songs[this.state.trackIndex] : null;
|
||||
const openAlbum = ALBUMS.find((a) => a.id === this.state.openAlbumId) || null;
|
||||
|
||||
const cats = this.categoryMatches();
|
||||
const total = songHits.length + albums.length + cats.length;
|
||||
const sel = total ? Math.min(this.state.selIndex, total - 1) : -1;
|
||||
|
||||
const categoryCards = cats.map((c, ci) => {
|
||||
const isSel = sel === songHits.length + ci;
|
||||
const books = c.albums.filter((a) => this.isBook(a)).length;
|
||||
const allBooks = books === c.albums.length;
|
||||
const tiles = c.albums.slice(0, 4).map((a) => ({
|
||||
style: `border-radius:6px;${this.coverBg(a, 7)}`,
|
||||
}));
|
||||
return {
|
||||
key: c.key,
|
||||
navIndex: songHits.length + ci,
|
||||
name: c.name,
|
||||
typeLabel: allBooks ? "📖 Hörbücher" : books ? "🎵📖 Gemischt" : "🎵 Musik",
|
||||
countLabel: `${c.albums.length} ${allBooks ? (c.albums.length === 1 ? "Hörbuch" : "Hörbücher") : c.albums.length === 1 ? "Album" : "Alben"}`,
|
||||
tiles,
|
||||
cardStyle: `background:${allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${allBooks ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:0 6px 18px oklch(15% 0.05 210 / .35);${
|
||||
isSel ? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);" : ""
|
||||
}`,
|
||||
};
|
||||
});
|
||||
|
||||
const albumCards = albums.map((a, ai) => {
|
||||
const isSel = sel === songHits.length + cats.length + ai;
|
||||
const ring = isSel
|
||||
? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);"
|
||||
: a.id === this.state.albumId
|
||||
? "outline:4px solid oklch(70% 0.16 340);"
|
||||
: "";
|
||||
const book = this.isBook(a);
|
||||
return {
|
||||
id: a.id,
|
||||
navIndex: songHits.length + cats.length + ai,
|
||||
title: a.title,
|
||||
artist: a.artist,
|
||||
typeLabel: book ? "📖 Hörbuch" : "🎵 Musik",
|
||||
coverLabel: book ? "HÖRBUCH COVER" : "ALBUM COVER",
|
||||
coverLabelStyle: book
|
||||
? "font-family:ui-monospace,Menlo,monospace;font-size:10px;color:oklch(98% 0 0 / .9);letter-spacing:.5px;text-align:center;margin-left:11%;padding:14px 10px;border:2px solid oklch(98% 0 0 / .55);border-radius:4px;"
|
||||
: "font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);letter-spacing:.5px;",
|
||||
songCountLabel: book ? `${a.songs.length} Kapitel` : `${a.songs.length} Songs`,
|
||||
bodyStyle: `padding:10px 12px 14px;${book ? "padding-right:22px;" : ""}`,
|
||||
badgeStyle: `position:absolute;top:8px;left:8px;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:${book ? "oklch(94% 0.09 88)" : "oklch(97% 0.01 210)"};color:oklch(26% 0.04 210);box-shadow:0 2px 6px oklch(15% 0.05 210 / .3);`,
|
||||
cardStyle: `background:${book ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${book ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:${
|
||||
book
|
||||
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7), 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
: "0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
};${ring}`,
|
||||
coverStyle: `width:100%;aspect-ratio:${book ? "0.82" : "1"};position:relative;display:flex;align-items:center;justify-content:center;${this.coverBg(a, 14)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const songRows = songHits.map((h, hi) => ({
|
||||
albumId: h.album.id,
|
||||
navIndex: hi,
|
||||
index: h.index,
|
||||
title: h.title,
|
||||
duration: h.duration,
|
||||
albumLine: `${this.isBook(h.album) ? "📖 " : ""}${h.album.title} · ${h.album.artist}`,
|
||||
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 16px;border-radius:14px;cursor:pointer;background:oklch(97% 0.01 210 / ${album && album.id === h.album.id && this.state.trackIndex === h.index ? ".22" : ".10"});${sel === hi ? "outline:4px solid oklch(97% 0.01 210);outline-offset:2px;" : ""}`,
|
||||
chipStyle: `width:38px;height:38px;flex:none;border-radius:${this.isBook(h.album) ? "6px 12px 12px 6px" : "10px"};${this.coverBg(h.album, 7)}`,
|
||||
}));
|
||||
|
||||
const openSongs = openAlbum
|
||||
? openAlbum.songs.map((s, i) => {
|
||||
const isCurrent = album && album.id === openAlbum.id && this.state.trackIndex === i;
|
||||
return {
|
||||
albumId: openAlbum.id,
|
||||
index: i,
|
||||
number: String(i + 1),
|
||||
title: s[0],
|
||||
duration: s[1],
|
||||
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 14px;border-radius:14px;cursor:pointer;background:${isCurrent ? "oklch(70% 0.16 340 / .16)" : "oklch(30% 0.03 210 / .05)"};`,
|
||||
numStyle: `width:30px;height:30px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;font:800 13px ui-monospace,Menlo,monospace;background:${isCurrent ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210)"};color:${isCurrent ? "#fff" : "oklch(35% 0.03 210)"};`,
|
||||
titleStyle: `color:${isCurrent ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)"};`,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const volumeBars = [0, 1, 2, 3, 4].map((i) => ({
|
||||
level: (i + 1) * 20,
|
||||
style: `border:none;padding:0;cursor:pointer;background:${this.state.volume >= (i + 1) * 20 ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210 / .4)"};`,
|
||||
}));
|
||||
|
||||
const parts = [];
|
||||
if (this.state.mode === "tracks") parts.push(`${songHits.length} Titel`);
|
||||
else if (albums.length) parts.push(`${albums.length} Album${albums.length === 1 ? "" : "en"}`);
|
||||
|
||||
return {
|
||||
filterAllStyle: this.filterPill("all", this.state.filter === "all"),
|
||||
filterMusicStyle: this.filterPill("music", this.state.filter === "music"),
|
||||
filterBookStyle: this.filterPill("book", this.state.filter === "book"),
|
||||
setFilterAll: () => this.setFilter("all"),
|
||||
setFilterMusic: () => this.setFilter("music"),
|
||||
setFilterBook: () => this.setFilter("book"),
|
||||
scrollerRef: (el) => { this._scroller = el; },
|
||||
gridRef: (el) => { this._grid = el; if (el) requestAnimationFrame(() => this.measureCols()); },
|
||||
showHelp: this.state.showHelp,
|
||||
toggleHelp: () => this.setState((s) => ({ showHelp: !s.showHelp })),
|
||||
hasSearch: this.state.search.length > 0,
|
||||
showSearchBar: this.state.search.length > 0 || this.state.mode === "tracks",
|
||||
modeLabel: this.state.mode === "tracks" ? "♪ Titel" : "🔎 Alben",
|
||||
searchDisplay: this.state.search ? `"${this.state.search}"` : "tippe …",
|
||||
resultCountLabel: parts.length ? parts.join(" · ") + " gefunden" : "nichts gefunden",
|
||||
albumSectionLabel: this.state.filter === "book" ? "Hörbücher" : this.state.filter === "music" ? "Alben" : "Alben & Hörbücher",
|
||||
categories: categoryCards,
|
||||
hasCategories: categoryCards.length > 0,
|
||||
categorySectionLabel: this.state.filter === "book" ? "Figuren" : this.state.filter === "music" ? "Künstler" : "Figuren & Künstler",
|
||||
openCategory: (e) => {
|
||||
this.playPop(440);
|
||||
this.setState({ category: e.currentTarget.dataset.key, selIndex: 0 });
|
||||
},
|
||||
hasCategoryCrumb: this.state.category !== null && !this.state.search,
|
||||
categoryCrumb: this.state.category || "",
|
||||
clearCategory: () => this.setState({ category: null, selIndex: 0 }),
|
||||
hasAlbumHits: albums.length > 0,
|
||||
hasSongHits: songHits.length > 0,
|
||||
hasNoResults: albums.length === 0 && songHits.length === 0 && cats.length === 0,
|
||||
songHitLabel: `${songHits.length} Treffer`,
|
||||
filteredAlbums: albumCards,
|
||||
songHits: songRows,
|
||||
openAlbumCard: (e) => {
|
||||
this.playPop(420);
|
||||
const nav = Number(e.currentTarget.dataset.navIndex);
|
||||
this.setState({ openAlbumId: Number(e.currentTarget.dataset.id), selIndex: isNaN(nav) ? this.state.selIndex : nav });
|
||||
},
|
||||
closeAlbum: () => this.setState({ openAlbumId: null }),
|
||||
stopClick: (e) => e.stopPropagation(),
|
||||
playOpenAlbum: () => {
|
||||
if (this.state.openAlbumId !== null) {
|
||||
this.playSong(this.state.openAlbumId, 0);
|
||||
this.setState({ openAlbumId: null });
|
||||
}
|
||||
},
|
||||
playSongHit: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.playSong(Number(d.album), Number(d.index));
|
||||
},
|
||||
hasOpenAlbum: openAlbum !== null,
|
||||
openAlbumTitle: openAlbum ? openAlbum.title : "",
|
||||
openAlbumArtist: openAlbum ? openAlbum.artist : "",
|
||||
openAlbumMeta: openAlbum
|
||||
? this.isBook(openAlbum)
|
||||
? `📖 Hörbuch · ${openAlbum.songs.length} Kapitel`
|
||||
: `🎵 Musik · ${openAlbum.songs.length} Songs`
|
||||
: "",
|
||||
openAlbumCoverStyle: openAlbum
|
||||
? `width:150px;height:150px;flex:none;border-radius:${this.isBook(openAlbum) ? "10px 18px 18px 10px" : "16px"};display:flex;align-items:center;justify-content:center;${this.coverBg(openAlbum, 12)}`
|
||||
: "display:none;",
|
||||
openAlbumSongs: openSongs,
|
||||
togglePlay: () => this.togglePlayInternal(),
|
||||
prevTrack: () => this.advance(-1),
|
||||
nextTrack: () => this.advance(1),
|
||||
isBrowse: this.state.view === "browse",
|
||||
isPlay: this.state.view === "play",
|
||||
goBrowse: () => this.setState({ view: "browse" }),
|
||||
|
||||
bigCoverStyle: album
|
||||
? `width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:${this.isBook(album) ? "18px 34px 34px 18px" : "28px"};display:flex;align-items:center;justify-content:center;box-shadow:0 20px 50px oklch(10% 0.05 210 / .55);${this.coverBg(album, 22)}`
|
||||
: "width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:28px;background:oklch(35% 0.03 210);",
|
||||
isPlaying: this.state.isPlaying,
|
||||
isPaused: !this.state.isPlaying,
|
||||
currentSongTitle: song ? song[0] : "Wähl ein Album!",
|
||||
currentAlbumLine: album ? `${this.isBook(album) ? "📖 " : ""}${album.title} · ${album.artist}` : "Tippen oder klicken",
|
||||
trackCounter: album ? `${this.isBook(album) ? "Kapitel" : "Song"} ${this.state.trackIndex + 1} von ${album.songs.length}` : "",
|
||||
playerCoverStyle: album ? this.coverBg(album, 8) : "background:oklch(35% 0.03 210);",
|
||||
albumRemainingLabel: album
|
||||
? `Noch ${this.clock(this.remainingSecs(album))} ${this.isBook(album) ? "im Hörbuch" : "im Album"}`
|
||||
: "",
|
||||
trackRemainingLabel: album && album.songs[this.state.trackIndex]
|
||||
? `−${this.clock(this.secs(album.songs[this.state.trackIndex][1]) * (1 - this.state.progress / 100))}`
|
||||
: "",
|
||||
progressStyle: `width:${this.state.progress}%;transition:width .2s linear;`,
|
||||
dolphinBobAnim: this.state.isPlaying ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||
volumeBars,
|
||||
setVolume: (e) => {
|
||||
this.playPop(500);
|
||||
this.setState({ volume: Number(e.currentTarget.dataset.level) });
|
||||
},
|
||||
muteToggle: () => this.setState((s) => ({ volume: s.volume > 0 ? 0 : 60 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
317
claude-design/Mein Zimmer.dc.html
Normal file
317
claude-design/Mein Zimmer.dc.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||
@keyframes dolphinFloat { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-12px) rotate(2deg); } }
|
||||
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||
a { color: oklch(80% 0.14 302); }
|
||||
a:hover { color: oklch(92% 0.10 302); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;width:100%;min-height:100vh;box-sizing:border-box;overflow:hidden;background:linear-gradient(180deg, oklch(56% 0.14 305) 0%, oklch(38% 0.12 300) 45%, oklch(21% 0.08 298) 100%);">
|
||||
|
||||
<div style="position:absolute;left:4%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 9s linear infinite;"></div>
|
||||
<div style="position:absolute;left:11%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(94% 0.03 302 / .35);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||
<div style="position:absolute;left:19%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||
<div style="position:absolute;left:27%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(94% 0.03 302 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:4s;"></div>
|
||||
<div style="position:absolute;left:34%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:3s;"></div>
|
||||
<div style="position:absolute;left:42%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(94% 0.03 302 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:5.5s;"></div>
|
||||
<div style="position:absolute;left:49%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 8.4s linear infinite;animation-delay:1.6s;"></div>
|
||||
<div style="position:absolute;left:57%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(94% 0.03 302 / .4);animation:bubbleRise 11.2s linear infinite;animation-delay:6.2s;"></div>
|
||||
<div style="position:absolute;left:64%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7.4s linear infinite;animation-delay:2.4s;"></div>
|
||||
<div style="position:absolute;left:72%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(94% 0.03 302 / .32);animation:bubbleRise 14s linear infinite;animation-delay:3.8s;"></div>
|
||||
<div style="position:absolute;left:80%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:5s;"></div>
|
||||
<div style="position:absolute;left:88%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(94% 0.03 302 / .28);animation:bubbleRise 15s linear infinite;animation-delay:1.2s;"></div>
|
||||
<div style="position:absolute;left:95%;bottom:-40px;width:13px;height:13px;border-radius:50%;background:oklch(94% 0.03 302 / .45);animation:bubbleRise 10.2s linear infinite;animation-delay:7s;"></div>
|
||||
|
||||
<div style="position:relative;z-index:1;padding-top:10px;">
|
||||
<dc-import name="AppHeader" title="Mein Zimmer" mascot="dolphin-remote.png" link-href="./Dolphin%20Beats.dc.html" link-label="♪ Musik" hint-size="100%,116px"></dc-import>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:0 32px 64px;box-sizing:border-box;">
|
||||
|
||||
<div style="margin-top:18px;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 302);text-align:center;margin-bottom:14px;">Szenen</div>
|
||||
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;">
|
||||
<sc-for list="{{ scenes }}" as="scene" hint-placeholder-count="5">
|
||||
<button onClick="{{ activateScene }}" data-id="{{ scene.id }}" style="{{ scene.style }}">
|
||||
<span style="font-size:22px;">{{ scene.emoji }}</span>
|
||||
<span>{{ scene.name }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(300px, 1fr));gap:20px;margin-top:30px;">
|
||||
|
||||
<sc-for list="{{ lights }}" as="light" hint-placeholder-count="4">
|
||||
<div style="{{ light.cardStyle }}">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="{{ light.iconWrapStyle }}">
|
||||
<sc-if value="{{ light.isBeyond }}" hint-placeholder-val="{{ true }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85"></circle>
|
||||
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"></circle>
|
||||
</svg>
|
||||
</sc-if>
|
||||
<sc-if value="{{ light.isStrip }}" hint-placeholder-val="{{ false }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<rect x="4" y="19" width="40" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<circle cx="13" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
<circle cx="24" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
<circle cx="35" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</sc-if>
|
||||
<sc-if value="{{ light.isCeiling }}" hint-placeholder-val="{{ false }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<path d="M24 4v7" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
||||
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||
<path d="M16 37h16" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"></path>
|
||||
<path d="M20 43h8" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".3"></path>
|
||||
</svg>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">{{ light.name }}</div>
|
||||
<button onClick="{{ toggleLight }}" data-id="{{ light.id }}" style="{{ light.switchStyle }}">
|
||||
<span style="{{ light.knobStyle }}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<div style="display:flex;gap:6px;">
|
||||
<sc-for list="{{ light.levels }}" as="lvl" hint-placeholder-count="5">
|
||||
<button onClick="{{ setLevel }}" data-id="{{ lvl.lightId }}" data-level="{{ lvl.value }}" style="{{ lvl.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ light.hasColors }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="margin-top:14px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||
<sc-for list="{{ light.swatches }}" as="sw" hint-placeholder-count="5">
|
||||
<button onClick="{{ setColor }}" data-id="{{ sw.lightId }}" data-color="{{ sw.value }}" title="{{ sw.name }}" style="{{ sw.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<div style="background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35);">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;background:oklch(88% 0.03 300);color:oklch(35% 0.05 300);">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" stroke-width="2.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">Rollo</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(45% 0.03 300 / .8);">{{ shutterLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;display:flex;gap:18px;align-items:stretch;">
|
||||
<div style="width:104px;flex:none;border-radius:12px;overflow:hidden;position:relative;background:linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200));border:3px solid oklch(35% 0.04 300);">
|
||||
<div style="position:absolute;left:0;right:0;top:0;{{ shutterFillStyle }}"></div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||
<sc-for list="{{ shutterPresets }}" as="p" hint-placeholder-count="4">
|
||||
<button onClick="{{ setShutter }}" data-value="{{ p.value }}" style="{{ p.style }}">{{ p.name }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
|
||||
<button onClick="{{ shutterUp }}" style="{{ upStyle }}">▲</button>
|
||||
<button onClick="{{ shutterStop }}" style="{{ stopStyle }}">■</button>
|
||||
<button onClick="{{ shutterDown }}" style="{{ downStyle }}">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 900}}">
|
||||
const SWATCHES = [
|
||||
{ name: "Warmweiß", value: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", value: "oklch(85% 0.16 95)" },
|
||||
{ name: "Korallenrot", value: "oklch(65% 0.20 25)" },
|
||||
{ name: "Delfinblau", value: "oklch(70% 0.15 235)" },
|
||||
{ name: "Riffgrün", value: "oklch(75% 0.16 155)" },
|
||||
{ name: "Quallenlila", value: "oklch(65% 0.18 310)" },
|
||||
];
|
||||
|
||||
const SCENES = [
|
||||
{ id: "lesen", name: "Lesen", emoji: "📖", lights: [{ on: true, level: 4, color: "oklch(90% 0.06 85)" }, { on: true, level: 2, color: "oklch(90% 0.06 85)" }, { on: false, level: 2, color: "oklch(70% 0.15 235)" }], ceiling: { on: true, level: 3 }, shutter: 0 },
|
||||
{ id: "spielen", name: "Spielen", emoji: "🐬", lights: [{ on: true, level: 5, color: "oklch(75% 0.16 155)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }, { on: true, level: 4, color: "oklch(65% 0.18 310)" }], ceiling: { on: true, level: 5 }, shutter: 0 },
|
||||
{ id: "party", name: "Disco", emoji: "🪩", lights: [{ on: true, level: 5, color: "oklch(65% 0.18 310)" }, { on: true, level: 5, color: "oklch(65% 0.20 25)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }], ceiling: { on: false, level: 2 }, shutter: 85 },
|
||||
{ id: "nacht", name: "Gute Nacht", emoji: "🌙", lights: [{ on: true, level: 1, color: "oklch(70% 0.15 235)" }, { on: false, level: 1, color: "oklch(70% 0.15 235)" }, { on: true, level: 1, color: "oklch(65% 0.18 310)" }], ceiling: { on: false, level: 1 }, shutter: 100 },
|
||||
{ id: "aufwachen", name: "Aufwachen", emoji: "🌞", lights: [{ on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: false, level: 2, color: "oklch(85% 0.16 95)" }], ceiling: { on: true, level: 2 }, shutter: 0 },
|
||||
];
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
lights: [
|
||||
{ id: "beyond1", kind: "beyond", name: "Hue Beyond links", on: true, level: 4, color: "oklch(70% 0.15 235)" },
|
||||
{ id: "beyond2", kind: "beyond", name: "Hue Beyond rechts", on: false, level: 3, color: "oklch(75% 0.16 155)" },
|
||||
{ id: "strip", kind: "strip", name: "Lichtband Bett", on: true, level: 3, color: "oklch(65% 0.18 310)" },
|
||||
{ id: "ceiling", kind: "ceiling", name: "Deckenlampe", on: true, level: 5, color: null },
|
||||
],
|
||||
shutter: 0,
|
||||
moving: null,
|
||||
scene: null,
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
}
|
||||
|
||||
patch(id, changes) {
|
||||
this.setState((s) => ({
|
||||
scene: null,
|
||||
lights: s.lights.map((l) => (l.id === id ? Object.assign({}, l, changes) : l)),
|
||||
}));
|
||||
}
|
||||
|
||||
move(dir) {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
this.setState({ moving: dir, scene: null });
|
||||
this._mover = setInterval(() => {
|
||||
this.setState((s) => {
|
||||
const next = s.shutter + dir * 4;
|
||||
if (next <= 0) { clearInterval(this._mover); this._mover = null; return { shutter: 0, moving: null }; }
|
||||
if (next >= 100) { clearInterval(this._mover); this._mover = null; return { shutter: 100, moving: null }; }
|
||||
return { shutter: next };
|
||||
});
|
||||
}, 90);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
this._mover = null;
|
||||
this.setState({ moving: null });
|
||||
}
|
||||
|
||||
glow(color, level) {
|
||||
return `0 0 0 4px ${color.replace(")", " / .28)")}, 0 10px 26px ${color.replace(")", ` / ${0.18 + level * 0.08})`)}`;
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const shutter = Math.round(this.state.shutter);
|
||||
|
||||
const lights = this.state.lights.map((l) => {
|
||||
const tint = l.color || "oklch(92% 0.05 90)";
|
||||
const active = l.on;
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
isBeyond: l.kind === "beyond",
|
||||
isStrip: l.kind === "strip",
|
||||
isCeiling: l.kind === "ceiling",
|
||||
hasColors: l.kind !== "ceiling",
|
||||
cardStyle: `background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35)${active ? `, 0 0 0 3px ${tint.replace(")", " / .55)")}` : ""};`,
|
||||
iconWrapStyle: `width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;transition:background .15s ease;background:${active ? tint : "oklch(88% 0.02 300)"};color:${active ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)"};${active ? `box-shadow:0 0 22px ${tint.replace(")", " / .7)")};` : ""}`,
|
||||
switchStyle: `width:74px;height:42px;flex:none;border:none;cursor:pointer;border-radius:999px;padding:4px;display:flex;justify-content:${active ? "flex-end" : "flex-start"};background:${active ? "oklch(62% 0.15 302)" : "oklch(84% 0.01 300)"};transition:background .15s ease;`,
|
||||
knobStyle: "width:34px;height:34px;border-radius:999px;background:#fff;box-shadow:0 2px 6px oklch(20% 0.03 300 / .4);display:block;",
|
||||
levels: [1, 2, 3, 4, 5].map((v) => ({
|
||||
lightId: l.id,
|
||||
value: v,
|
||||
style: `flex:1;height:46px;min-width:0;border:none;cursor:pointer;border-radius:12px;background:${active && l.level >= v ? tint : "oklch(88% 0.02 300)"};opacity:${active ? 1 : 0.55};`,
|
||||
})),
|
||||
swatches: SWATCHES.map((sw) => ({
|
||||
lightId: l.id,
|
||||
value: sw.value,
|
||||
name: sw.name,
|
||||
style: `width:46px;height:46px;border-radius:999px;cursor:pointer;background:${sw.value};border:${l.color === sw.value ? "4px solid oklch(28% 0.05 300)" : "4px solid oklch(96% 0.012 300 / .8)"};box-shadow:0 3px 8px oklch(20% 0.03 300 / .35);`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const presetBtn = (active) =>
|
||||
`border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:0 10px;height:48px;border-radius:14px;background:${active ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${active ? "#fff" : "oklch(28% 0.04 300)"};`;
|
||||
|
||||
const label =
|
||||
shutter >= 99 ? "Ganz zu" :
|
||||
shutter >= 80 ? "Fast zu" :
|
||||
shutter >= 40 ? "Halb zu" :
|
||||
shutter <= 1 ? "Offen" : `${shutter}% zu`;
|
||||
|
||||
return {
|
||||
lights,
|
||||
toggleLight: (e) => {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const l = this.state.lights.find((x) => x.id === id);
|
||||
this.patch(id, { on: !l.on });
|
||||
},
|
||||
setLevel: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.patch(d.id, { level: Number(d.level), on: true });
|
||||
},
|
||||
setColor: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.patch(d.id, { color: d.color, on: true });
|
||||
},
|
||||
shutterLabel: this.state.moving ? (this.state.moving > 0 ? "Fährt runter …" : "Fährt hoch …") : label,
|
||||
shutterFillStyle: `height:${shutter}%;transition:height .2s linear;background:repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px);box-shadow:0 4px 10px oklch(20% 0.03 300 / .4);`,
|
||||
shutterPresets: [
|
||||
{ name: "Offen", value: 0, style: presetBtn(shutter <= 1) },
|
||||
{ name: "Halb zu", value: 50, style: presetBtn(shutter > 1 && shutter < 80) },
|
||||
{ name: "Fast zu", value: 85, style: presetBtn(shutter >= 80 && shutter < 99) },
|
||||
{ name: "Ganz zu", value: 100, style: presetBtn(shutter >= 99) },
|
||||
],
|
||||
setShutter: (e) => {
|
||||
this.stop();
|
||||
this.setState({ shutter: Number(e.currentTarget.dataset.value), scene: null });
|
||||
},
|
||||
shutterUp: () => this.move(-1),
|
||||
shutterDown: () => this.move(1),
|
||||
shutterStop: () => this.stop(),
|
||||
upStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === -1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === -1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||
downStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === 1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === 1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||
stopStyle: "border:none;cursor:pointer;font-family:inherit;font-size:18px;font-weight:900;height:52px;border-radius:14px;background:oklch(65% 0.18 25);color:#fff;",
|
||||
scenes: SCENES.map((sc) => ({
|
||||
id: sc.id,
|
||||
name: sc.name,
|
||||
emoji: sc.emoji,
|
||||
style: `border:none;cursor:pointer;font-family:inherit;font-size:15px;font-weight:800;padding:12px 20px;border-radius:999px;display:flex;align-items:center;gap:10px;min-height:52px;background:${
|
||||
this.state.scene === sc.id ? "oklch(97% 0.01 302)" : "oklch(97% 0.01 302 / .18)"
|
||||
};color:${this.state.scene === sc.id ? "oklch(26% 0.05 300)" : "oklch(97% 0.01 302)"};box-shadow:${
|
||||
this.state.scene === sc.id ? "0 6px 18px oklch(15% 0.05 300 / .4)" : "none"
|
||||
};`,
|
||||
})),
|
||||
activateScene: (e) => {
|
||||
const sc = SCENES.find((x) => x.id === e.currentTarget.dataset.id);
|
||||
if (!sc) return;
|
||||
this.stop();
|
||||
this.setState((s) => ({
|
||||
scene: sc.id,
|
||||
shutter: sc.shutter,
|
||||
lights: s.lights.map((l, i) =>
|
||||
l.kind === "ceiling"
|
||||
? Object.assign({}, l, sc.ceiling)
|
||||
: Object.assign({}, l, sc.lights[i] || {})
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
claude-design/dolphin-mascot.png
Normal file
BIN
claude-design/dolphin-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/dolphin-remote.png
Normal file
BIN
claude-design/dolphin-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
1911
claude-design/support.js
Normal file
1911
claude-design/support.js
Normal file
File diff suppressed because it is too large
Load Diff
435
claude-design/three-d-stage.js
Normal file
435
claude-design/three-d-stage.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||
/* BEGIN USAGE */
|
||||
/**
|
||||
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
|
||||
*
|
||||
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
|
||||
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
|
||||
* right-drag to pan), a camera auto-framed to the object's bounds, resize
|
||||
* handling, and a download toolbar that exports the current object as
|
||||
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
|
||||
* GLB is the interchange format every modern 3D tool imports.
|
||||
*
|
||||
* three.js loads through the page's import map. Include this EXACT pinned
|
||||
* map in <head>, before any module runs — versions and integrity hashes
|
||||
* stay together (same map the "3D object" skill mandates):
|
||||
*
|
||||
* <script type="importmap">
|
||||
* {
|
||||
* "imports": {
|
||||
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
|
||||
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
|
||||
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
|
||||
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
|
||||
* },
|
||||
* "integrity": {
|
||||
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
|
||||
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
|
||||
* }
|
||||
* }
|
||||
* </script>
|
||||
*
|
||||
* Usage:
|
||||
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
|
||||
* <three-d-stage name="rocket"></three-d-stage>
|
||||
* <script src="three-d-stage.js"></script>
|
||||
* <script type="module">
|
||||
* const stage = document.querySelector('three-d-stage');
|
||||
* const { THREE } = await stage.ready;
|
||||
* const model = new THREE.Group();
|
||||
* // …build the model out of named meshes with named materials —
|
||||
* // the names become the o / usemtl entries in the exported OBJ…
|
||||
* stage.setObject(model);
|
||||
* </script>
|
||||
*
|
||||
* Attributes:
|
||||
* name — export file basename (default "model")
|
||||
* background — CSS color behind the scene (default a warm paper tone)
|
||||
* autorotate — when present, a slow turntable until the user interacts
|
||||
*
|
||||
* Model in real-world meters, centered on the origin, y-up — exports
|
||||
* inherit the scene's units and orientation. The stage fills its own box;
|
||||
* size it with ordinary CSS (default 100vw/100vh page hero).
|
||||
*
|
||||
* Default setup: neutral studio lighting (hemisphere + key + fill), a
|
||||
* soft ground shadow, and NO environment map — so high metalness has
|
||||
* nothing to reflect and renders near-black. Cap metalness around
|
||||
* 0.3–0.4 and carry a metal look with a brighter base color. The copied
|
||||
* file is yours: adjust the lights, shadow, or background in _boot()
|
||||
* when the object needs a different look.
|
||||
*/
|
||||
/* END USAGE */
|
||||
|
||||
(() => {
|
||||
const stylesheet = `
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--stage-bg, #f0eee6);
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas { display: block; outline: none; }
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.toolbar button {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(20, 20, 19, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #1a1915;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
padding: 9px 12px;
|
||||
cursor: default;
|
||||
}
|
||||
.toolbar button:hover { background: #fff; }
|
||||
.toolbar button:active { transform: translateY(1px); }
|
||||
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
.note {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
max-width: 60%;
|
||||
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: rgba(26, 25, 21, 0.55);
|
||||
user-select: none;
|
||||
}
|
||||
.err {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #8a2f20;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
`;
|
||||
|
||||
function download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
|
||||
/** Tell the host an export attempt settled — telemetry only. The host
|
||||
* (HTMLViewer) verifies the source and re-reads these fields defensively
|
||||
* before counting; nothing else crosses the frame boundary. Guarded so
|
||||
* telemetry can never break the download path. */
|
||||
function notifyExport(format, ok) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
|
||||
'*'
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
class ThreeDStage extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = stylesheet;
|
||||
root.appendChild(style);
|
||||
this._err = document.createElement('div');
|
||||
this._err.className = 'err';
|
||||
root.appendChild(this._err);
|
||||
const note = document.createElement('div');
|
||||
note.className = 'note';
|
||||
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
|
||||
root.appendChild(note);
|
||||
this._toolbar = document.createElement('div');
|
||||
this._toolbar.className = 'toolbar';
|
||||
this._objBtn = document.createElement('button');
|
||||
this._objBtn.type = 'button';
|
||||
this._objBtn.textContent = 'Download OBJ + MTL';
|
||||
this._objBtn.addEventListener('click', () => this._runExport('obj'));
|
||||
this._glbBtn = document.createElement('button');
|
||||
this._glbBtn.type = 'button';
|
||||
this._glbBtn.textContent = 'Download GLB';
|
||||
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
|
||||
this._toolbar.appendChild(this._objBtn);
|
||||
this._toolbar.appendChild(this._glbBtn);
|
||||
root.appendChild(this._toolbar);
|
||||
this._setButtonsEnabled(false);
|
||||
/** Resolves with { THREE } once the scene is live — build the model
|
||||
* in `await stage.ready` so nothing races the library load. */
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this._readyResolve = resolve;
|
||||
this._readyReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._booted) {
|
||||
// Re-attached after a removal — resume what disconnected stopped.
|
||||
if (this._renderer) {
|
||||
this._renderer.setAnimationLoop(this._loop);
|
||||
this._ro && this._ro.observe(this);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._booted = true;
|
||||
this._boot().catch((err) => {
|
||||
this._err.style.display = 'flex';
|
||||
this._err.textContent =
|
||||
'three.js failed to load.\n' +
|
||||
'Check that the pinned <script type="importmap"> from the usage ' +
|
||||
'notes is in <head> before any module script.\n\n' +
|
||||
String(err && err.message ? err.message : err);
|
||||
this._readyReject(err);
|
||||
});
|
||||
}
|
||||
|
||||
async _boot() {
|
||||
const bg = this.getAttribute('background');
|
||||
if (bg) this.style.setProperty('--stage-bg', bg);
|
||||
const [THREE, controlsMod] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/controls/OrbitControls.js'),
|
||||
]);
|
||||
this._THREE = THREE;
|
||||
// preserveDrawingBuffer keeps the last frame readable after
|
||||
// compositing (toDataURL / drawImage) — it's what lets the
|
||||
// screenshot tools capture the scene instead of a blank canvas.
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this._renderer = renderer;
|
||||
this.shadowRoot.insertBefore(renderer.domElement, this._err);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
this._scene = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
|
||||
camera.position.set(3, 2.2, 4);
|
||||
this._camera = camera;
|
||||
|
||||
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
this._controls = controls;
|
||||
|
||||
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
|
||||
// and a dim fill from behind so silhouettes never go black.
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||
key.position.set(4, 7, 5);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.bias = -0.0002;
|
||||
this._key = key;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
|
||||
fill.position.set(-5, 3, -4);
|
||||
scene.add(fill);
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(200, 200),
|
||||
new THREE.ShadowMaterial({ opacity: 0.18 })
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
this._ground = ground;
|
||||
scene.add(ground);
|
||||
|
||||
this._autorotate = this.hasAttribute('autorotate');
|
||||
controls.autoRotate = this._autorotate;
|
||||
controls.autoRotateSpeed = 1.2;
|
||||
controls.addEventListener('start', () => {
|
||||
controls.autoRotate = false;
|
||||
});
|
||||
|
||||
const fit = () => {
|
||||
const w = this.clientWidth || 1;
|
||||
const h = this.clientHeight || 1;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
fit();
|
||||
this._ro = new ResizeObserver(fit);
|
||||
this._loop = () => {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
// Detached while three.js was fetching? Stay idle — the
|
||||
// connectedCallback resume starts the loop and observer on
|
||||
// reattach.
|
||||
if (this.isConnected) {
|
||||
this._ro.observe(this);
|
||||
renderer.setAnimationLoop(this._loop);
|
||||
}
|
||||
|
||||
this._readyResolve({ THREE });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Stop rendering and observing while detached; connectedCallback
|
||||
// resumes both. (The renderer itself is kept — a move within the
|
||||
// document must not rebuild the scene.)
|
||||
if (this._renderer) this._renderer.setAnimationLoop(null);
|
||||
if (this._ro) this._ro.disconnect();
|
||||
}
|
||||
|
||||
/** Show (and own) the object. Replaces any previous object, enables
|
||||
* shadows on every mesh, rests it on the ground plane, and frames
|
||||
* the camera to its bounds. */
|
||||
setObject(object) {
|
||||
const THREE = this._THREE;
|
||||
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
|
||||
if (this._object) this._scene.remove(this._object);
|
||||
this._object = object;
|
||||
object.traverse((o) => {
|
||||
if (o.isMesh) {
|
||||
o.castShadow = true;
|
||||
o.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (!box.isEmpty()) {
|
||||
// Rest the object on the ground without moving its origin.
|
||||
this._ground.position.y = box.min.y;
|
||||
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||
const dist =
|
||||
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
|
||||
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
|
||||
this._camera.position
|
||||
.copy(sphere.center)
|
||||
.add(dir.multiplyScalar(dist));
|
||||
this._camera.near = Math.max(dist / 100, 0.01);
|
||||
this._camera.far = dist * 100;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._controls.target.copy(sphere.center);
|
||||
this._controls.update();
|
||||
const span = sphere.radius * 3;
|
||||
this._key.shadow.camera.left = -span;
|
||||
this._key.shadow.camera.right = span;
|
||||
this._key.shadow.camera.top = span;
|
||||
this._key.shadow.camera.bottom = -span;
|
||||
this._key.shadow.camera.updateProjectionMatrix();
|
||||
}
|
||||
this._scene.add(object);
|
||||
this._setButtonsEnabled(true);
|
||||
}
|
||||
|
||||
get _basename() {
|
||||
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
|
||||
}
|
||||
|
||||
_setButtonsEnabled(on) {
|
||||
this._objBtn.disabled = !on;
|
||||
this._glbBtn.disabled = !on;
|
||||
}
|
||||
|
||||
/** Every mesh and material needs a unique name for o/usemtl lines —
|
||||
* fill in stable fallbacks, and return the unique material list. */
|
||||
_nameParts() {
|
||||
const mats = [];
|
||||
const seen = new Set();
|
||||
let meshI = 0;
|
||||
let matI = 0;
|
||||
this._object.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
if (!o.name) o.name = 'part_' + meshI;
|
||||
meshI += 1;
|
||||
const list = Array.isArray(o.material) ? o.material : [o.material];
|
||||
for (const m of list) {
|
||||
if (!m || mats.includes(m)) continue;
|
||||
if (!m.name) {
|
||||
m.name = 'mat_' + matI;
|
||||
matI += 1;
|
||||
}
|
||||
while (seen.has(m.name)) {
|
||||
m.name = m.name + '_' + matI;
|
||||
matI += 1;
|
||||
}
|
||||
seen.add(m.name);
|
||||
mats.push(m);
|
||||
}
|
||||
});
|
||||
return mats;
|
||||
}
|
||||
|
||||
/** One export attempt, reported to the host however it settles.
|
||||
* Rethrows so a failure stays visible on the guest console exactly as
|
||||
* before. The no-object early return is not an attempt (the toolbar is
|
||||
* disabled until the model loads) and reports nothing. */
|
||||
async _runExport(format) {
|
||||
if (!this._object) return;
|
||||
try {
|
||||
await (format === 'obj' ? this._exportObj() : this._exportGlb());
|
||||
notifyExport(format, true);
|
||||
} catch (err) {
|
||||
notifyExport(format, false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async _exportObj() {
|
||||
if (!this._object) return;
|
||||
const mod = await import('three/addons/exporters/OBJExporter.js');
|
||||
const mats = this._nameParts();
|
||||
const base = this._basename;
|
||||
const obj =
|
||||
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
|
||||
let mtl = '# Exported by three-d-stage\n';
|
||||
for (const m of mats) {
|
||||
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
|
||||
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
|
||||
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
|
||||
mtl += 'newmtl ' + m.name + '\n';
|
||||
mtl +=
|
||||
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
|
||||
mtl += 'Ks 0.2000 0.2000 0.2000\n';
|
||||
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
|
||||
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
|
||||
}
|
||||
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
|
||||
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
|
||||
}
|
||||
|
||||
async _exportGlb() {
|
||||
if (!this._object) return;
|
||||
const mod = await import('three/addons/exporters/GLTFExporter.js');
|
||||
this._nameParts();
|
||||
const base = this._basename;
|
||||
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
|
||||
binary: true,
|
||||
});
|
||||
download(
|
||||
new Blob([buf], { type: 'model/gltf-binary' }),
|
||||
base + '.glb'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('three-d-stage', ThreeDStage);
|
||||
})();
|
||||
@@ -1,42 +1,141 @@
|
||||
# MusicMouse — repo overview
|
||||
|
||||
Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`.
|
||||
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||
|
||||
## What this is
|
||||
|
||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device.
|
||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||
Assistant integration.
|
||||
|
||||
## Repo layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. |
|
||||
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. |
|
||||
| `.vscode/` | Editor settings (C++ header associations). |
|
||||
|
||||
There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs.
|
||||
| `claude-design/` | The interaction and visual specs the web front-end was built from, as standalone HTML mockups with hardcoded data: "Dolphin Beats" (the player), "Mein Zimmer" (the room page) and "Delphin Uhr" (the clock overlay). `web/` is the real implementation of all three; the mockups are kept as the reference for the keyboard model and the styling. |
|
||||
| `scripts/` | `check.sh` (every check, see below) and `sync-library.sh` (seed a device's library and analysis cache). |
|
||||
| `docs/` | This file. |
|
||||
|
||||
## How the pieces talk to each other
|
||||
|
||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side.
|
||||
- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`.
|
||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||
firmware log text interleaved on the same link. The Python side lives in
|
||||
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||
`esp-firmware/src/Messages.h`.
|
||||
|
||||
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||
breaks a test instead of just the LEDs.
|
||||
|
||||
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||
out of step with a figure someone puts on the reader. See
|
||||
`python-backend/README.md` for the endpoint list.
|
||||
|
||||
- **`python-backend` ↔ Home Assistant**: two separate paths.
|
||||
- MQTT, for the mouse itself. The backend publishes three discoverable lights, a
|
||||
player sensor, a volume number, transport buttons, device triggers for every button
|
||||
and touch area, and a tag scanner for the RFID reader. Behaviour like "the left ear
|
||||
turns the room light pink" is an HA automation, not backend code. See
|
||||
`python-backend/README.md` for the trigger topics and the old colour mapping.
|
||||
- HTTP, for the web front-end's room page only: `/api/ha/...` proxies state reads and
|
||||
service calls to Home Assistant's REST API with the token from `general.ha`, so the
|
||||
token never reaches the browser.
|
||||
|
||||
- **`python-backend` ↔ IR remote**: a TCP client for lircd (`general.lirc`), turned into
|
||||
the same intents as every other front-end.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
python python-backend/main.py <config_dir>
|
||||
```sh
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
```
|
||||
|
||||
`main.py` expects `<config_dir>/config.yml` (schema documented in the new `python-backend/config.yml.example` — no real config was previously checked in or documented). In production this is deployed as a systemd service reading music from `/media/musicmouse/`; see `esp-firmware/musicmouse.service` for the unit template — **note its `ExecStart` path (`.../espmusicmouse/host_driver/main.py`) is stale**, referencing the pre-reorg directory layout from before the `bd8925a "Cleaned up repository"` commit moved things to `python-backend/`. Update that path before relying on the service file.
|
||||
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||
|
||||
## Config schema (see `python-backend/config.yml.example`)
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}`
|
||||
- `figures.<name>.{id, colors, media_files}` — `id` is a hex RFID tag id, `colors` is a list of 4 colors (`primary, secondary, background, accent`, each `"#rrggbb"` or `"wNN"`), `media_files` is optional (auto-globbed from the config dir by figure name if omitted).
|
||||
`general.serial_port: simulate` does the same thing from the config, and
|
||||
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||
|
||||
## Known gaps / notes for agents
|
||||
Or with no hardware *and* no audio:
|
||||
|
||||
- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing).
|
||||
- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths.
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
```
|
||||
|
||||
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||
systemd unit for the device.
|
||||
|
||||
For front-end work, run the backend (either way above) and then:
|
||||
|
||||
```sh
|
||||
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||
```
|
||||
|
||||
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||
production so one process serves both the UI and the API.
|
||||
|
||||
## Config
|
||||
|
||||
`python-backend/config.yml.example` documents the schema. In short:
|
||||
|
||||
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||
audio_extensions, podcast_episode_limit}`, plus optional sections `mqtt`, `web`, `ha`
|
||||
(room page), `lirc` (IR remote), `screen` (monitor on/off), `tippen` (typing game)
|
||||
and `clock` (school bus countdown).
|
||||
- `figures.<name>.{id, colors, kind}` — `id` is a 5-byte hex RFID tag, unique per
|
||||
figure; `colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"`
|
||||
or `"wNN"`; `kind` is `music` (default) or `book`.
|
||||
- `remote.<digit>` — what each IR-remote number key plays. Written by the web UI.
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names, not settings, because each has quirks the scanner knows about:
|
||||
|
||||
```
|
||||
<root>/Figuren/<figure name>/ one folder per figurine (or album subfolders
|
||||
in it, up to six, picked on the LED ring)
|
||||
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
```
|
||||
|
||||
Config is validated with pydantic: unknown keys are errors, and every problem is
|
||||
reported at once.
|
||||
|
||||
## Notes for agents
|
||||
|
||||
- There is no CI. `scripts/check.sh` runs every check the repo has - backend
|
||||
`pytest`, `ruff` and `mypy --strict`, web `vitest`, `tsc` and `eslint` - and is the
|
||||
gate: run it before proposing changes. A checker that cannot run (mypy once aborted
|
||||
on numpy's stubs for two weeks without anyone noticing) is a failure, not a skip.
|
||||
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
|
||||
the behaviour is end-to-end — those files are executed by the test suite.
|
||||
- `python-backend/notebooks/` is LED-effect scratch work, not part of the app.
|
||||
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||
- The front-end has `vitest` over the pure modules (`web/src/lib/`), `tsc` and `eslint`;
|
||||
there is no component-level test harness.
|
||||
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||
and size, so **a change to how the scanner derives a title, artist or series is
|
||||
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||
`musicmouse/library/cache.py` when you touch that logic.
|
||||
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||
picklable, and an analyzer that records state in its own instance (a test double
|
||||
counting calls) only behaves as written with `analysis_workers=1`.
|
||||
|
||||
121
docs/agent-tasks/01-web-authentication.md
Normal file
121
docs/agent-tasks/01-web-authentication.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Task: password-protect the backend's HTTP API
|
||||
|
||||
## Why
|
||||
|
||||
The backend's HTTP API has no authentication and listens on `0.0.0.0`. The worst part
|
||||
is the room page's Home Assistant proxy
|
||||
(`python-backend/musicmouse/services/web/api.py`, `_proxy` and
|
||||
`POST /api/ha/services/{domain}/{service}`): it forwards *any* service call on *any*
|
||||
entity with the stored long-lived HA token. Anything on the LAN can unlock a lock,
|
||||
disarm an alarm or restart Home Assistant through it. Parent mode (`/api/settings`,
|
||||
`/api/remote/mapping`, `/api/timer`) is only hidden, not protected.
|
||||
|
||||
## What the owner decided
|
||||
|
||||
A browser authenticates **once** with a password, and then stays logged in for about
|
||||
**a year**. The config stores only a **hash** of the password, never the password
|
||||
itself. Use this design; do not substitute another one (no entity allow-list instead,
|
||||
no per-endpoint tokens, no OAuth).
|
||||
|
||||
## Design
|
||||
|
||||
### Config
|
||||
|
||||
- Add `general.web.password_hash` (in `WebConfig`, `musicmouse/config.py`). It is
|
||||
**required** whenever the `web` section is present. Follow the repo's existing
|
||||
convention for opting out of something important (see `SIMULATE` for `serial_port`
|
||||
and `alsa_device`): the literal value `"disabled"` turns authentication off, logs a
|
||||
warning on **every** boot, and is the only way to run without it. A missing key is a
|
||||
config error. Config errors already say which key is wrong.
|
||||
- Hash format: `scrypt$<n>$<r>$<p>$<salt b64>$<hash b64>` using `hashlib.scrypt` from
|
||||
the standard library. No new dependency. Choose parameters that verify in well under
|
||||
a second on a Raspberry Pi 4 (for example n=2**14, r=8, p=1), and measure on the
|
||||
desktop to sanity-check. Verify with `hmac.compare_digest`.
|
||||
- Add a CLI subcommand to set it. For example,
|
||||
`python -m musicmouse --config config.yml --set-password` prompts twice with
|
||||
`getpass`, hashes, and writes the key back into `config.yml` using the existing
|
||||
round-trip helpers in `musicmouse/services/web/settings.py` (`load_document`,
|
||||
`atomic_write`), so comments survive. Exit afterwards and start nothing else.
|
||||
- Document the key and the command in `python-backend/config.yml.example`.
|
||||
|
||||
### Sessions
|
||||
|
||||
- `POST /api/auth/login` with `{"password": "..."}`. On success it sets a cookie and
|
||||
returns 204; on failure it returns 401 after a fixed delay of about 1 s (cheap brute-force
|
||||
damping; no lockout state needed).
|
||||
- The cookie (`mm_session`): `HttpOnly`, `SameSite=Strict`, `Path=/`,
|
||||
`Max-Age` = 365 days. **Not** `Secure`: the device is served over plain HTTP on the
|
||||
LAN, and a `Secure` cookie would never be sent back.
|
||||
- The cookie value is a stateless signed token, `<expiry unix seconds>.<HMAC-SHA256>`.
|
||||
The HMAC key is derived from a persistent random secret *and* the current
|
||||
`password_hash`, so **changing the password logs every browser out** without extra
|
||||
bookkeeping. Keep the secret in a file under `general.library.cache` (for example
|
||||
`session-secret`, 32 random bytes, created on first start with mode 0600). Deleting
|
||||
the cache logs everyone out, which is acceptable; say so in the docs.
|
||||
- Optional, cheap, and nice: re-issue the cookie with a fresh year on any authenticated
|
||||
request when fewer than 30 days remain, so a device in daily use never expires.
|
||||
- `POST /api/auth/logout` clears the cookie. `GET /api/auth` returns 204 or 401, so the
|
||||
front-end can check without side effects.
|
||||
|
||||
### What is protected
|
||||
|
||||
- **Every** route under `/api`, including the websocket `/api/ws`, except
|
||||
`/api/auth/*`. Implement it once, as a FastAPI dependency on the router in
|
||||
`build_router` (or a small middleware), not route by route. The websocket checks the
|
||||
cookie *before* `accept()` and closes with code 4401 when it is missing or invalid.
|
||||
- **Not** the static front-end files: the login screen has to load.
|
||||
- **No localhost exemption.** The kiosk on the device logs in once like everyone else.
|
||||
An exemption by peer address breaks silently the day a reverse proxy is put in front
|
||||
of it (every request then looks local).
|
||||
- MQTT, the IR remote and the serial link are not HTTP and are out of scope.
|
||||
- Keep the room page's proxy as it is otherwise. Do not add an entity allow-list in
|
||||
this task.
|
||||
|
||||
### Front-end (`web/`)
|
||||
|
||||
- `src/api/client.ts`: a 401 from any call raises a dedicated `Unauthorized` error.
|
||||
`fetchOrNullOn404` / `fetchOrNullOnError` must not swallow a 401 as "not configured".
|
||||
- A login screen: one password field, German copy ("Passwort", "Anmelden", "Falsches
|
||||
Passwort"), styled like the existing sheets (`.overlay` / `.sheet`). It must work with
|
||||
only a keyboard (the kiosk has no pointer). Show it when the first `/api/state` or
|
||||
`/api/library` call, or the websocket, reports unauthorized. On success, reload the
|
||||
library, the state and the websocket. The simplest correct way is `location.reload()`.
|
||||
- `src/hooks/usePlayerState.ts`: on a 4401 close, stop the reconnect loop and show the
|
||||
login screen instead of retrying every 1.5 s.
|
||||
- The service worker (`public/sw.js`) must not cache `/api/auth/*`. It already avoids
|
||||
`/api` state; check it.
|
||||
|
||||
## Tests (backend)
|
||||
|
||||
- Every existing web test goes through the API. Give the test client fixture in
|
||||
`tests/test_web.py` (and `test_remote_api.py`, `test_tippen_api.py`) a logged-in
|
||||
cookie, so the existing tests stay as they are. Add a separate unauthenticated client
|
||||
fixture for the new tests.
|
||||
- New tests:
|
||||
- Each `/api` route returns 401 without the cookie. Generate the list from the router
|
||||
so a new route cannot slip through unprotected.
|
||||
- The websocket is refused without the cookie.
|
||||
- Login succeeds with the right password and fails with a wrong one.
|
||||
- A token past its expiry is rejected.
|
||||
- Changing `password_hash` invalidates existing cookies.
|
||||
- `"disabled"` lets everything through and logs the warning.
|
||||
- A missing key is a config error.
|
||||
- The `--set-password` command writes a hash that verifies and keeps the file's
|
||||
comments.
|
||||
- The fixed failure delay must not slow the suite down: inject it, as the codebase
|
||||
injects `Clock`.
|
||||
|
||||
## Docs to update
|
||||
|
||||
- `python-backend/README.md`: the "Parent mode" paragraph that says there is no
|
||||
authentication; the route table; a short "Authentication" section.
|
||||
- `web/README.md`: the "Parent mode" paragraph.
|
||||
- `docs/REPO_OVERVIEW.md`: the backend ↔ web paragraph and the config summary.
|
||||
- The comment block above the room-control routes in `api.py`, which justifies the open
|
||||
proxy with "trusted LAN device".
|
||||
|
||||
## Done when
|
||||
|
||||
`scripts/check.sh` passes, and a manual run against `--simulate` with a hash set shows
|
||||
the login screen, logs in, survives a backend restart without asking again, and asks
|
||||
again after the password is changed.
|
||||
121
docs/agent-tasks/04-one-input-policy.md
Normal file
121
docs/agent-tasks/04-one-input-policy.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Task: one behaviour per button, whatever it is pressed on
|
||||
|
||||
## Why
|
||||
|
||||
The backend's README promises that front-ends only translate input into intents and that
|
||||
`musicmouse/reactions/` is the one place that decides what happens. Three behaviours
|
||||
break that promise today, so the same request does different things depending on where
|
||||
it came from:
|
||||
|
||||
| Behaviour | Web front-end | IR remote | Mouse buttons / knob | MQTT |
|
||||
|---|---|---|---|---|
|
||||
| Next/previous on a podcast | seeks ±30 s (`web/src/App.tsx`, `skipPodcast`) | skips a track | skips a track | skips a track |
|
||||
| Unmute | jumps to 60 % (`UNMUTE_PERCENT` in `App.tsx`) | restores the previous volume (`LircService._toggle_mute`) | - | - |
|
||||
| Volume step | J/K: 10 % (`VOLUME_STEP` in `web/src/lib/keyboard.ts`); +/- buttons: `volume_increment` | `volume_increment` | `volume_increment` × detents | absolute only |
|
||||
|
||||
There is also a confirmed bug. With `min_volume > 0`, the remote's mute never unmutes:
|
||||
"mute" asks for volume 0, the player clamps it to `min_volume`, the next press sees
|
||||
`volume > 0` and mutes again.
|
||||
|
||||
## What the owner decided
|
||||
|
||||
1. **Next/previous on a podcast episode always seeks 30 s** forward/back, from every
|
||||
input: web (buttons and keys), IR remote, the mouse's buttons, MQTT.
|
||||
2. **Unmute always restores the volume from before muting**, from every input.
|
||||
3. **Parent mode gets three separate volume-step settings**: the web keyboard's J/K,
|
||||
the mouse's rotary knob, and the IR remote. Each is configured independently.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Podcast skip lives in the backend
|
||||
|
||||
- In `musicmouse/reactions/playback.py`, `next_track` / `previous_track` look at the
|
||||
current album (`app.album_for(app.player.playlist)`). For a podcast episode they seek
|
||||
instead: `position ± PODCAST_SKIP_SECONDS` (30, a module constant), clamped to
|
||||
`[0, duration]`. Take the duration from the library track when the player does not
|
||||
know it yet, the way `services/web/state.py` does.
|
||||
- "Is this a podcast?" is decided by **one** helper. If `docs/agent-tasks/06-podcast-kind.md`
|
||||
has landed, that is `album.kind == "podcast"`. If not, add `is_podcast(album)` next to
|
||||
`Album` (`musicmouse/library/models.py`) that checks the section's
|
||||
`album_unit == "episode"`, and let task 06 change its body.
|
||||
- `web/src/App.tsx`: the `next` / `previous` actions always call `api.next()` /
|
||||
`api.previous()`. Delete `skipPodcast` and `PODCAST_SKIP_SECONDS`. Drop the optimistic
|
||||
`position: 0` patch too: the client no longer knows what the backend will do. If the
|
||||
≤0.5 s until the next position frame is visibly laggy, let `StateHub` push a
|
||||
`position` frame right after a next/previous/seek intent has been handled. Don't
|
||||
re-add client-side policy.
|
||||
- The mouse's left/right buttons only act while playing (`button_pressed`); keep that.
|
||||
|
||||
### 2. Mute is a player state, not a volume
|
||||
|
||||
- Add `muted: bool` to the `Player` protocol, plus `set_muted(muted, *, source)`,
|
||||
implemented in `PlayerBase`. `VlcPlayer` applies it with libVLC's
|
||||
`audio_set_mute`; `FakePlayer` just records it. Muting does **not** touch `volume`,
|
||||
so unmuting restores it by construction, and `min_volume` cannot interfere.
|
||||
- Announce changes with a new `StateEvent` (for example `MuteChanged`) and add it to
|
||||
`SNAPSHOT_EVENTS` in `services/web/hub.py`. A test fails until every `StateEvent` is
|
||||
sorted into that list or `IGNORED_EVENTS`.
|
||||
- A new intent, `ToggleMuteRequested`, handled by a reaction. Any volume change
|
||||
(`SetVolumeRequested`, `VolumeChangeRequested`, the step intent below) also unmutes;
|
||||
that is what every TV remote does.
|
||||
- `POST /api/mute/toggle`. The web's Shift+M and the mute button call it. Delete
|
||||
`UNMUTE_PERCENT` and `onMute`'s volume arithmetic.
|
||||
- `LircService`: `KEY_MUTE` emits `ToggleMuteRequested`. Delete `_pre_mute_volume` and
|
||||
`_toggle_mute`.
|
||||
- The state snapshot (`PlayerStateOut`, `web/src/api/types.ts`) gets `muted`. The volume
|
||||
bars show muted distinctly instead of showing volume 0.
|
||||
- MQTT is out of scope. Its volume number stays absolute, and setting it unmutes via
|
||||
the rule above.
|
||||
|
||||
### 3. Three volume steps
|
||||
|
||||
- Config: replace `general.volume_increment` with
|
||||
`general.volume_steps: {keyboard: int, knob: int, remote: int}`, all in **device
|
||||
units** like `min_volume` / `max_volume`, each `ge=1, le=100`. Defaults: `keyboard: 10`,
|
||||
`knob: 5`, `remote: 5` (today's behaviour for knob and remote).
|
||||
- `volume_increment` in an existing `config.yml` must fail with a message naming the
|
||||
replacement ("replaced by volume_steps.knob / .remote / .keyboard"). Config
|
||||
validation rejects unknown keys anyway; make the message specific rather than the
|
||||
generic "unknown option".
|
||||
- One intent: `VolumeStepRequested(direction: Literal[-1, 1], count: int = 1,
|
||||
input: Literal["keyboard", "knob", "remote"])`. One reaction multiplies by the
|
||||
configured step and calls `change_volume`.
|
||||
- `rotary_turned` emits it with `input="knob"`, `count=abs(event.increment)`.
|
||||
- `LircService` volume keys emit it with `input="remote"`.
|
||||
- `POST /api/volume` takes `{"steps": n, "input": "keyboard" | "remote"}` instead of
|
||||
bare `steps`. J/K send `input: "keyboard"`. The on-screen −/+ buttons keep taking
|
||||
the remote's step, as 1254790 deliberately chose, so they send `input: "remote"`.
|
||||
- `{"percent": n}` stays for the volume bars.
|
||||
- Parent mode: `SettingsIn` / `SettingsOut` (`services/web/schemas.py`),
|
||||
`read_settings` / `write_settings` (`services/web/settings.py`, writing the nested
|
||||
mapping), and `web/src/components/ParentPanel.tsx` get three fields, labelled in
|
||||
German (for example "Lautstärke-Schritt: Tastatur (J/K)", "… Drehknopf",
|
||||
"… Fernbedienung"). They replace the single "rotary step". Saving applies live, like
|
||||
the other settings.
|
||||
- Update `python-backend/config.yml.example`, the route table in
|
||||
`python-backend/README.md`, `web/README.md`'s key table, and `HelpOverlay.tsx`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Reactions (`tests/test_reactions.py`, through the simulator):
|
||||
- Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a
|
||||
`Kinderpodcasts` folder in the test library (see `tests/conftest.py`).
|
||||
- Next/previous on music still changes track.
|
||||
- Mute then unmute restores the exact volume, including with `min_volume > 0` (the
|
||||
regression).
|
||||
- A volume change while muted unmutes.
|
||||
- Each of the three inputs moves by its own configured step.
|
||||
- `tests/test_lirc.py`: the mute key and the volume keys emit the new intents.
|
||||
- `tests/test_web.py`: `/api/mute/toggle`; `/api/volume` with `input`; the settings
|
||||
round-trip for `volume_steps`, which keeps the file's comments.
|
||||
- `tests/test_config.py`: the defaults, and the specific error for `volume_increment`.
|
||||
- `web/src/lib/__tests__/keyboard.test.ts`: J/K produce the step action, not a
|
||||
percentage.
|
||||
|
||||
## Done when
|
||||
|
||||
`scripts/check.sh` passes, `grep -rn "PODCAST_SKIP\|UNMUTE_PERCENT\|volume_increment"`
|
||||
finds nothing in `web/src` and `python-backend/musicmouse` apart from the config error
|
||||
that names the old key, and in `--simulate` a
|
||||
podcast skips 30 s from the web, from `press right` in the REPL, and from a simulated
|
||||
remote key.
|
||||
75
docs/agent-tasks/05-firmware-serial-frames.md
Normal file
75
docs/agent-tasks/05-firmware-serial-frames.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Task: stop the firmware garbling its own messages to the host
|
||||
|
||||
## Why
|
||||
|
||||
`sendMessageToHost` (`esp-firmware/src/Messages.h`) writes one frame as four separate
|
||||
`Serial.write` calls: magic, type, size, payload. It runs on two FreeRTOS tasks at once:
|
||||
|
||||
- the Arduino `loop()` task, for touch, rotary encoder and button events
|
||||
(`esp-firmware/src/main.cpp`);
|
||||
- the RC522 reader's own task (`rc522_task` in `esp-firmware/lib/nfc/rc522.c`, priority
|
||||
4), which calls `tagHandler` → `sendMessageToHost(MsgRfidTokenRead{...})` directly.
|
||||
|
||||
When a tag read coincides with a button or touch event, the two frames interleave on
|
||||
the wire. The host's decoder (`python-backend/musicmouse/devices/wire.py`,
|
||||
`FrameDecoder`) resynchronises on the magic token and drops what it cannot parse. So
|
||||
the symptom is an occasionally **lost figure placement/removal or button press**, with a
|
||||
"Discarding bad frame from firmware" warning in the backend log.
|
||||
|
||||
## What the owner decided
|
||||
|
||||
Fix it in whatever way keeps the firmware simplest. A mutex was the first idea. **Use
|
||||
the queue below instead, unless it turns out not to work**, because it is simpler:
|
||||
|
||||
- With a queue, every frame and every log line is written from `loop()` alone, so no
|
||||
lock is needed anywhere, including around the `Serial.println` log lines that could
|
||||
otherwise land in the middle of a frame.
|
||||
- It is exactly the pattern `main.cpp` already uses for the rotary encoder
|
||||
(`eventQueueRotaryEncoder`, drained in `handleRotaryEncoder()` with
|
||||
`xQueueReceive(..., 0)`).
|
||||
|
||||
A mutex would need wrapping every `Serial.write` / `Serial.print` in the firmware to be
|
||||
complete. That is more code and easier to get wrong later.
|
||||
|
||||
## Design
|
||||
|
||||
In `esp-firmware/src/main.cpp`:
|
||||
|
||||
1. Create a FreeRTOS queue for tag reads in `setupRfidReader()`, **before**
|
||||
`rc522_start`, so the callback can never run without it. Item type: the 5-byte tag id
|
||||
(`MsgRfidTokenRead` itself is fine, it is a packed 5-byte struct). Depth: a handful
|
||||
(4) is plenty; the reader polls every 125 ms.
|
||||
2. `tagHandler` (runs on the RC522 task) only fills a `MsgRfidTokenRead`, all zeros
|
||||
for "removed" exactly as now, and `xQueueSend(queue, &msg, 0)`. If the queue is full,
|
||||
drop the read; the reader reports the state again on the next change. Nothing in
|
||||
`tagHandler` touches `Serial` any more.
|
||||
3. Add `handleRfid()`, called from `loop()` next to `handleRotaryEncoder()`. It drains
|
||||
the queue with `xQueueReceive(..., 0)` and calls `sendMessageToHost` for each item.
|
||||
4. Leave `Messages.h` alone. With a single writer, the four `Serial.write` calls are
|
||||
fine as they are.
|
||||
|
||||
Do not change the wire format, message ids or the host. `python-backend/tests/test_wire.py`
|
||||
parses `Messages.h` and pins the ids, and it must keep passing.
|
||||
|
||||
Out of scope: the touch thresholds, debouncing, anything in `lib/`.
|
||||
|
||||
## Checking it
|
||||
|
||||
There are no firmware unit tests that reach `main.cpp` (the `native` env only builds the
|
||||
LED effects), and this machine may not have PlatformIO. So:
|
||||
|
||||
- It must build: `pio run -e esp32` in `esp-firmware/`. If PlatformIO is not
|
||||
available, say so in the hand-off instead of claiming it builds.
|
||||
- `pio run -e native` still builds `src/host_test.cpp`. That env excludes `main.cpp`, so
|
||||
this only guards against accidental changes to shared headers.
|
||||
- `scripts/check.sh backend` still passes (`test_wire.py`).
|
||||
- Manual test on the device, with the backend running at `--log-level DEBUG`. Hold a
|
||||
hand on an ear touch area while repeatedly placing and removing a figure for a
|
||||
minute, and turn the knob while doing it. Expect every placement and removal to show
|
||||
up as `RfidTokenRead`, and **no** "Discarding bad frame from firmware" warnings.
|
||||
Before the fix, the same exercise should produce some.
|
||||
|
||||
## Done when
|
||||
|
||||
The firmware builds, the host tests pass, `tagHandler` contains no `Serial` call, and the
|
||||
manual test shows no discarded frames.
|
||||
89
docs/agent-tasks/06-podcast-kind.md
Normal file
89
docs/agent-tasks/06-podcast-kind.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Task: make "podcast" a kind of album
|
||||
|
||||
## Why
|
||||
|
||||
`AlbumKind` is `Literal["music", "book"]` (`python-backend/musicmouse/library/sections.py`),
|
||||
and the `Kinderpodcasts` shelf is declared `kind="book"`. So "is this a podcast?" is
|
||||
answered differently in each layer:
|
||||
|
||||
- web: `album.section === "Kinderpodcasts"` (`web/src/lib/search.ts`, `groupOf`), and
|
||||
everything else goes through `groupOf(album) === "podcasts"` (App, PlayView,
|
||||
BrowseView, MobileBrowse, keyboard.ts, remote.ts, ambience.ts, useTrackDetail);
|
||||
- backend: `SECTIONS[album.section].album_unit == "episode"` (`MusicLibrary.latest_episode`,
|
||||
`tippen/rewards.py`);
|
||||
- typing game: reward kinds `"episode"` and `"podcast_episode"`
|
||||
(`services/web/tippen_api.py`).
|
||||
|
||||
The shelf's folder name is therefore a de-facto API string the front-end depends on.
|
||||
`todo.md` already asks for a cleaner podcast data model.
|
||||
|
||||
## What the owner decided
|
||||
|
||||
Add `"podcast"` to the album kind. Podcast-ness is then a property of the album, carried
|
||||
in the index and the API, and nobody compares section names.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend
|
||||
|
||||
- `AlbumKind = Literal["music", "book", "podcast"]`. `SECTIONS["Kinderpodcasts"]` gets
|
||||
`kind="podcast"`.
|
||||
- `Section.album_unit` stays. It is the scanner's structural fact (one album per file),
|
||||
not a classification. Keep using it in the scanner (`scan_library`) and in
|
||||
`podcast_feeds.find_feed_shows`. Every place that asks "is this album a podcast?"
|
||||
switches to `album.kind == "podcast"`:
|
||||
- `MusicLibrary.latest_episode`;
|
||||
- `tippen/rewards.py` (`_chronological_episodes`, and the `album_unit == "folder"`
|
||||
branches that really mean "not a podcast");
|
||||
- `tippen_api._animation_kind`;
|
||||
- the `is_podcast` helper, if task 04 added one: change its body, keep its callers.
|
||||
- `scanner.scan_album` derives `series` only for `kind == "book"`. Check that episode
|
||||
albums still get `series = folder.name` (they do, in `scan_episodes`) and that
|
||||
nothing else branches on `"book"` meaning "book or podcast".
|
||||
`grep -rn '"book"' python-backend/musicmouse` and decide per hit.
|
||||
- `FigureConfig.kind` stays `Literal["music", "book"]`. A figurine holding a podcast is
|
||||
not a thing. Type `Config.figure_kinds` accordingly.
|
||||
- `cache._album_from_json` validates the kind: accept `"podcast"`. Bump
|
||||
`_INDEX_VERSION` (with a comment line, like the existing ones), so every cached
|
||||
podcast album is re-scanned with the new kind instead of being read back as
|
||||
`"book"`.
|
||||
- `_ANALYZED_KINDS` stays `("music",)`.
|
||||
- `services/web/schemas.py`: `AlbumOut.kind` and `PlayerStateOut.kind` become the
|
||||
`AlbumKind` literal instead of `str`, so the OpenAPI schema says what the values are.
|
||||
|
||||
### Web
|
||||
|
||||
- `src/api/types.ts`: `AlbumKind = "music" | "book" | "podcast"`.
|
||||
- `src/lib/search.ts`, `groupOf`: map from `kind` alone (`podcast` → `"podcasts"`,
|
||||
`book` → `"audiobooks"`, else `"music"`). No `section` comparison may remain
|
||||
anywhere in `web/src`.
|
||||
- `src/lib/covers.ts`: `isBook` and `aspectOfKind` treat `"book"` specially. Podcasts
|
||||
are drawn like books today *because* they were `kind: "book"`. Keep them looking
|
||||
exactly as they do now: decide explicitly whether `isBook` / `aspectOfKind` include
|
||||
`"podcast"`, and check BrowseView's `mostlyBooks` / `allPodcasts` tiles and the play
|
||||
view. The result must be visually unchanged; compare screenshots of the podcast
|
||||
shelf, a podcast category and the podcast play view before and after.
|
||||
- Anything else that reads `state.kind` or `album.kind`: grep and decide per hit.
|
||||
|
||||
### Leave alone
|
||||
|
||||
The `section` field stays in the API; it is still useful for display and debugging.
|
||||
Don't rename the shelves, the folders or the `Group` names.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_library.py`: a `Kinderpodcasts` episode scans as `kind == "podcast"`; a
|
||||
cache written at the previous `_INDEX_VERSION` is discarded; `latest_episode` still
|
||||
works.
|
||||
- `tests/test_tippen_rewards.py` / `test_tippen_api.py`: episode rewards still resolve
|
||||
and still animate as `podcast_episode`.
|
||||
- `tests/test_web.py`: `/api/library` reports `"podcast"`.
|
||||
- `web/src/lib/__tests__/search.test.ts`: `groupOf` uses `kind`; an album whose section
|
||||
says `Kinderpodcasts` but whose kind is `music` is music. That proves nothing compares
|
||||
the section any more.
|
||||
|
||||
## Done when
|
||||
|
||||
`scripts/check.sh` passes, `grep -rn "Kinderpodcasts" web/src` finds nothing, the
|
||||
backend's "is it a podcast" checks all read `album.kind`, and the podcast UI looks the
|
||||
same as before.
|
||||
222
docs/agent-tasks/07-backend-and-frontend-cleanups.md
Normal file
222
docs/agent-tasks/07-backend-and-frontend-cleanups.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Task: eight independent cleanups
|
||||
|
||||
Each section below is a separate change: do them as separate commits, in any order, and
|
||||
run `scripts/check.sh` after each. Each comes from a code review of commit `a55cc4f`;
|
||||
the "why" says what is wrong and, where known, which commit introduced it. Do not
|
||||
change behaviour beyond what a section asks for.
|
||||
|
||||
---
|
||||
|
||||
## A. Races in library updates and concurrent rescans (review item 7)
|
||||
|
||||
**Why.** `MusicLibrary.refresh()` and `MusicLibrary._publish_analysis()`
|
||||
(`python-backend/musicmouse/library/__init__.py`) both do
|
||||
`self._entries = await asyncio.to_thread(...)` from a snapshot taken before the await.
|
||||
Whichever finishes last wins, so an analysis batch that started before a rescan
|
||||
finished can overwrite the freshly scanned index (a new podcast episode disappears
|
||||
until the next rescan). Both also write `index.json` from worker threads at the same
|
||||
time. Rescans are not serialised either: `POST /api/library/refresh` fires an
|
||||
unretained `asyncio.create_task` (`# noqa: RUF006` in `services/web/api.py`), and the
|
||||
podcast poller rescans on its own (`App.rescan_library`, introduced with the poller in
|
||||
57afc32).
|
||||
|
||||
Separately, `VlcPlayer` (`devices/player.py`) mutates `_playing`, `_index` and
|
||||
`_playlist_changed` both from libVLC's callback thread (`_on_playing`, `_on_stopped`,
|
||||
`_on_playlist_end`, `_on_next_item`) and from the event loop (`_poll`, the actions).
|
||||
|
||||
**Do.**
|
||||
- One `asyncio.Lock` in `MusicLibrary` held around every read-modify-write of
|
||||
`_entries` plus its `store_index`: the whole of `refresh()`, and the fold-swap-store
|
||||
in `_publish_analysis()`. The analysis workers keep running while a scan holds the
|
||||
lock; only publishing waits.
|
||||
- `App.rescan_library` coalesces. While one rescan runs, further calls wait for the
|
||||
running one plus at most one follow-up, not N parallel scans. Keep a reference to the
|
||||
task the refresh endpoint starts, and drop the `noqa`.
|
||||
- `VlcPlayer`'s libVLC callbacks do nothing but hop onto the loop (the bus already
|
||||
knows the loop; give `PlayerBase` a small `_on_loop(fn)` using
|
||||
`loop.call_soon_threadsafe`). All state mutation then happens on the loop thread.
|
||||
Update the comment above the callbacks.
|
||||
|
||||
**Test.** A library test with a scan made artificially slow (an injected scanner or a
|
||||
patched `scan_library`) and an analysis publish racing it. The new album must survive.
|
||||
Two concurrent `rescan_library` calls run the scanner at most twice.
|
||||
|
||||
---
|
||||
|
||||
## B. Library cache invalidation gaps (review item 8)
|
||||
|
||||
**Why.** `scan_library` (`library/scanner.py`) reuses a cached folder album whenever
|
||||
its *audio files'* names, sizes and mtimes are unchanged (`Fingerprint`). So:
|
||||
1. changing a figure's `kind:` in `config.yml` has no effect until the figure's files
|
||||
change: the cached album keeps the old kind;
|
||||
2. a `cover.jpg` (or any of `_COVER_NAMES`) added to an album folder later is never
|
||||
picked up. Podcast episodes include their sidecar covers in the fingerprint; folder
|
||||
albums do not;
|
||||
3. covers of albums that no longer exist (deleted folders, episodes pruned by the
|
||||
podcast poller) stay in `cache/covers/` forever.
|
||||
|
||||
**Do.**
|
||||
- Folder albums: include the existing `_COVER_NAMES` files in the fingerprint, like
|
||||
`scan_episodes` does for sidecars.
|
||||
- Figure albums: reuse a cached entry only if its `kind` equals the configured one.
|
||||
- After a scan, delete `covers/<id>.jpg` and `covers/<id>.thumb.jpg` for ids not in
|
||||
the new index. Do **not** garbage-collect `analysis/`: it is expensive, content-keyed
|
||||
and documented as safe to keep.
|
||||
- Bump `_INDEX_VERSION` with a comment line, because the fingerprint shape changes.
|
||||
|
||||
**Test.** One test per gap in `tests/test_library.py`.
|
||||
|
||||
---
|
||||
|
||||
## C. The LED "off" animation is sent twice when a figure is removed (review item 11)
|
||||
|
||||
**Why.** Removing a figure makes `reactions/lighting.figure_placed_or_removed` run
|
||||
`off_animation`. The player pausing then emits `PlaybackChanged(playing=False)`, and
|
||||
`web_playback`, whose guard is "no figure on the reader", runs `off_animation` again.
|
||||
That makes 6 LED writes instead of 3 and restarts the reverse swipe halfway through.
|
||||
`web_playback` came in with the web front-end (edb6e5e).
|
||||
|
||||
**Do.** Make `off_animation` idempotent: skip a zone whose current effect
|
||||
(`app.mouse.effect(zone)`) is already the off effect it would send, and skip the button
|
||||
brightness when it is already 0. That keeps both reactions simple and covers any future
|
||||
third caller.
|
||||
|
||||
**Test.** In `tests/test_reactions.py`: removing a figure while playing produces
|
||||
exactly three `LedEffectChanged` events, one per zone.
|
||||
|
||||
---
|
||||
|
||||
## D. `config.yml` has no single owner (review item 13)
|
||||
|
||||
**Why.**
|
||||
- `PUT /api/settings` (`services/web/api.py`) applies values with `setattr` on the
|
||||
pydantic config objects. That skips validation, so the handler re-implements the
|
||||
min/max/initial volume checks by hand.
|
||||
- `PUT /api/remote/mapping` replaces `app.config.remote`.
|
||||
- Both endpoints read, modify and write `config.yml` from worker threads
|
||||
(`write_settings`, `write_mapping`) with no lock, so two saves at once can lose one.
|
||||
- Memory is updated *before* the file is written, so a failed write leaves the running
|
||||
config and the file disagreeing.
|
||||
|
||||
**Do.** One small `ConfigStore` (for example `musicmouse/config_store.py`) owns the path,
|
||||
the in-memory `Config` and a lock. An update:
|
||||
1. validates the merged values through the real pydantic models, so the
|
||||
`_check_volumes` validator runs; no duplicated checks in the endpoint;
|
||||
2. writes the file (round-trip, comments kept, via the existing `load_document` /
|
||||
`atomic_write`);
|
||||
3. only then applies the values to the live objects.
|
||||
|
||||
Objects that read the config live (`ScreenService` reads `auto_off_minutes`, the player's
|
||||
volume limits) must keep seeing changes: either keep mutating the same objects after
|
||||
validation, or have them read through the store. Pick one and say which in the
|
||||
docstring. The settings and remote-mapping endpoints both go through the store. If
|
||||
`docs/agent-tasks/01-web-authentication.md` has landed, its `--set-password` command
|
||||
should use it too.
|
||||
|
||||
**Test.** Invalid settings are rejected by the model's own validator, with an
|
||||
unchanged file and unchanged memory. Two concurrent saves (settings and mapping) both
|
||||
end up in the file.
|
||||
|
||||
---
|
||||
|
||||
## E. Generate the front-end's API types (review item 15)
|
||||
|
||||
**Why.** `web/src/api/types.ts` (~300 lines) hand-mirrors
|
||||
`python-backend/musicmouse/services/web/schemas.py`. FastAPI already describes every
|
||||
route and model at `/api/openapi.json`. (A third copy lived in the Slint front-end,
|
||||
which has been removed.)
|
||||
|
||||
**Do.**
|
||||
- A backend test (for example `tests/test_openapi.py`) builds the app the way
|
||||
`tests/test_web.py` does and compares `api.openapi()` with a committed
|
||||
`web/src/api/openapi.json`. With `UPDATE_OPENAPI=1` it rewrites the file instead. The
|
||||
test fails whenever the API changes and the snapshot was not refreshed.
|
||||
- `web`: add `openapi-typescript` as a dev dependency and an `npm run gen:api` script
|
||||
that generates `src/api/schema.d.ts` from that JSON. Make `npm run check` fail when
|
||||
the generated file is stale: use the tool's own check mode if it has one (verify),
|
||||
otherwise regenerate to a temp file and diff.
|
||||
- `types.ts` keeps its exported names (`Album`, `PlayerState`, ...) as aliases of
|
||||
`components["schemas"][...]`, so no component changes. Move the useful doc comments
|
||||
onto the pydantic fields (`Field(description=...)`), where they reach both sides.
|
||||
- The websocket messages (`ServerMessage`) are not in OpenAPI. Keep them hand-written
|
||||
in `types.ts` with a comment saying so.
|
||||
|
||||
---
|
||||
|
||||
## F. Copy-pasted helpers (review item 16)
|
||||
|
||||
**Why.**
|
||||
- `ScreenService.blubb` (`services/screen.py`) is a line-for-line copy of
|
||||
`sound.play_wav`. `sound.py` was extracted in a55cc4f, but the screen service was
|
||||
never moved onto it.
|
||||
- The "did a person do this?" filter (an `IntentEvent`, not `LedEffectRequested`, not
|
||||
`source == "system"`) is duplicated in `ScreenService._on_event` and
|
||||
`TimerService._on_event`.
|
||||
- There are five atomic-write helpers: `library/cache._write_atomic`,
|
||||
`services/web/settings.atomic_write`, `tippen/progress.save_progress`,
|
||||
`school_days.save_calendar` and `library/podcast_feeds._save_failed`. Three of them
|
||||
name the temp file `<name>.tmp<pid>`, which collides when two threads of the same
|
||||
process write the same file. That really happens: settings and remote-mapping saves
|
||||
both run in `asyncio.to_thread`.
|
||||
|
||||
**Do.**
|
||||
- `ScreenService.blubb` calls `play_wav(self.alsa_device, wav, timeout=self.config.timeout)`.
|
||||
- `is_person(event: Event) -> bool` next to the event classes in `events.py`, used by
|
||||
both services (keep each service's own extra exclusions).
|
||||
- One `write_atomic(path, text)` (for example `musicmouse/fileio.py`) using
|
||||
`tempfile.NamedTemporaryFile(dir=path.parent, delete=False)` and `os.replace`, used by
|
||||
all five. Keep the temp file a dotfile where it sits in the music library, so the
|
||||
scanner never picks it up. The YAML writer dumps to a string first.
|
||||
|
||||
**Test.** The existing tests cover behaviour. Add one for `write_atomic`, two threads
|
||||
writing the same path at once, which today can fail.
|
||||
|
||||
---
|
||||
|
||||
## G. Inject time everywhere (review item 17)
|
||||
|
||||
**Why.** The backend's rule (see `musicmouse/clock.py`) is that anything that waits takes a
|
||||
`Clock`, and `TimerService` takes a `wall` callable for the date. Exceptions:
|
||||
- `PodcastFeedService.run` and `StateHub.run` call `asyncio.sleep` directly;
|
||||
- `services/web/state.clock_state` uses `date.today()`;
|
||||
- `POST /api/timer` (`services/web/api.py`) uses `datetime.now()`.
|
||||
|
||||
**Do.** Give `PodcastFeedService` and `StateHub` a `clock`, as the other services have,
|
||||
wired in `__main__._build_services` / `WebService`. Put one `wall: Callable[[],
|
||||
datetime]` on `App` (default `datetime.now`) and use it in `TimerService`,
|
||||
`clock_state` and the timer endpoint, so a test can pin "today" in one place.
|
||||
|
||||
**Test.** The podcast service test runs on `FakeClock` instead of real sleeps; a
|
||||
clock-state test pins the date through `App.wall`.
|
||||
|
||||
---
|
||||
|
||||
## H. Break up the largest files (review item 19)
|
||||
|
||||
**Why.** Every feature since the rearchitecture was added to the same few files.
|
||||
- `web/src/App.tsx` is 909 lines with ~40 hooks, routes pages via flags, and
|
||||
defines `ToolButton`, `CornerButton`, `ConnectionDot`, `Splash` and the assign toast
|
||||
inline.
|
||||
- `BrowseView.tsx` is 932 lines.
|
||||
- About 300 static `style={{...}}` objects sit next to five CSS files.
|
||||
- `services/web/api.py` is one router for seven features: library, state, playback,
|
||||
parent mode, typing game, remote, Home Assistant.
|
||||
- One sign of insertion rather than placement: the comment
|
||||
`/** The one place a keyboard action, a click or a tap all end up. */` in `App.tsx`
|
||||
sits above `saveMapping`, but describes `run`.
|
||||
|
||||
**Do** (mechanical moves only, no behaviour change; one commit per bullet):
|
||||
- `api.py`: one router module per feature under `services/web/routes/`, assembled in
|
||||
`build_router`. URLs unchanged.
|
||||
- `App.tsx`: move the four small components into `components/`, the remote-mapping
|
||||
state into `hooks/useRemoteMapping.ts`, and the kiosk listeners (screen activity,
|
||||
silencing a ringing timer) into `hooks/useKioskActivity.ts`. Put the misplaced
|
||||
comment back above `run`.
|
||||
- `BrowseView.tsx`: split by screen (root shelves, category, results), whichever seams
|
||||
the file already has.
|
||||
- Inline styles: move **static** style objects in `App.tsx`, `BrowseView.tsx` and
|
||||
`ParentPanel.tsx` into classes in the existing CSS files. Leave dynamic ones (colours
|
||||
from album data, computed sizes) inline.
|
||||
|
||||
Check visually in the dev server, at desktop, phone (`usePhoneLayout`) and `?pi=1`
|
||||
sizes, that nothing moved.
|
||||
20
docs/agent-tasks/README.md
Normal file
20
docs/agent-tasks/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Agent tasks
|
||||
|
||||
Self-contained instructions for fixes that came out of a code review of commit `a55cc4f`.
|
||||
Each file says why, what the owner decided, the design, the tests, and when it is done.
|
||||
The numbers are the review's item numbers.
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `01-web-authentication.md` | Password login, once per browser for a year, in front of the whole `/api` (including the Home Assistant proxy). |
|
||||
| `04-one-input-policy.md` | Podcast next/previous seeks 30 s from every input; mute restores the previous volume; three separate volume steps in parent mode. |
|
||||
| `05-firmware-serial-frames.md` | Stop RFID frames interleaving with button frames on the serial link. |
|
||||
| `06-podcast-kind.md` | `"podcast"` as an album kind instead of comparing the `Kinderpodcasts` folder name. |
|
||||
| `07-backend-and-frontend-cleanups.md` | Eight independent cleanups: library races, cache invalidation, double LED off-animation, a config owner, generated API types, shared helpers, injected time, splitting the largest files. |
|
||||
|
||||
Order: `06` before `04` (04's podcast check uses 06's kind; it says how to proceed if
|
||||
06 has not landed). `07 D` is easier after `01`. The rest are independent.
|
||||
|
||||
For every task: one commit per logical change, `scripts/check.sh` passing before each
|
||||
commit, no pushing. When a task is done, delete its file in the same commit, so this
|
||||
folder only ever lists open work.
|
||||
@@ -2,93 +2,72 @@
|
||||
|
||||
#include "drivers/Esp32DriverRGBW.h"
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/rmt.h>
|
||||
#include <driver/rmt_encoder.h>
|
||||
#include <esp_check.h>
|
||||
|
||||
// Timing constants
|
||||
static constexpr uint16_t DIVIDER = 4;
|
||||
static constexpr double RMT_DURATION_NS = 12.5; // Minimum time of a single RMT duration based on clock ns
|
||||
// Timing constants, in ns
|
||||
static constexpr uint32_t RESOLUTION_HZ = 20 * 1000 * 1000; // one RMT tick = 50 ns
|
||||
static constexpr uint32_t TICK_NS = 1000 * 1000 * 1000 / RESOLUTION_HZ;
|
||||
static constexpr uint32_t T0H = 300;
|
||||
static constexpr uint32_t T0L = 900;
|
||||
static constexpr uint32_t T1H = 600;
|
||||
static constexpr uint32_t T1L = 600;
|
||||
static constexpr uint32_t TRS = 80000;
|
||||
static constexpr rmt_item32_t bit0Data = {uint32_t(T0H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T0L / (RMT_DURATION_NS * DIVIDER)), 0};
|
||||
static constexpr rmt_item32_t bit1Data = {uint32_t(T1H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T1L / (RMT_DURATION_NS * DIVIDER)), 0};
|
||||
|
||||
// Function registered at the RMT driver that converts regular uint8_t values containing r,g,b,w values
|
||||
// to rmt_item32_t for each bit. (8 bit -> 32 * 8 bit)
|
||||
static void IRAM_ATTR uint8ToRmtAdaptor(const void *src, rmt_item32_t *dest, size_t srcSize,
|
||||
size_t wantedNum, size_t *translatedSize, size_t *itemNum)
|
||||
static rmt_symbol_word_t symbol(uint32_t highNs, uint32_t lowNs)
|
||||
{
|
||||
if (src == NULL || dest == NULL)
|
||||
{
|
||||
*translatedSize = 0;
|
||||
*itemNum = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
size_t size = 0;
|
||||
size_t num = 0;
|
||||
uint8_t *psrc = (uint8_t *)src;
|
||||
rmt_item32_t *pdest = dest;
|
||||
while (size < srcSize && num < wantedNum)
|
||||
{
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
// MSB first
|
||||
const bool isBitSet = *psrc & (1 << (7 - i));
|
||||
pdest->val = isBitSet ? bit1Data.val : bit0Data.val;
|
||||
num++;
|
||||
pdest++;
|
||||
}
|
||||
size++;
|
||||
psrc++;
|
||||
}
|
||||
*translatedSize = size;
|
||||
*itemNum = num;
|
||||
rmt_symbol_word_t s = {};
|
||||
s.duration0 = highNs / TICK_NS;
|
||||
s.level0 = 1;
|
||||
s.duration1 = lowNs / TICK_NS;
|
||||
s.level1 = 0;
|
||||
return s;
|
||||
}
|
||||
|
||||
void Esp32DriverRGBW::begin(int gpio, int rmtChannel)
|
||||
void Esp32DriverRGBW::begin(int gpio)
|
||||
{
|
||||
rmtChannel_ = rmtChannel;
|
||||
transmitting_ = false;
|
||||
|
||||
rmt_config_t rmt_tx;
|
||||
rmt_tx.channel = static_cast<rmt_channel_t>(rmtChannel);
|
||||
rmt_tx.gpio_num = static_cast<gpio_num_t>(gpio);
|
||||
rmt_tx.rmt_mode = RMT_MODE_TX;
|
||||
rmt_tx.mem_block_num = 1;
|
||||
rmt_tx.clk_div = DIVIDER;
|
||||
rmt_tx.tx_config.loop_en = false;
|
||||
rmt_tx.tx_config.carrier_level = RMT_CARRIER_LEVEL_LOW;
|
||||
rmt_tx.tx_config.carrier_en = false;
|
||||
rmt_tx.tx_config.idle_level = RMT_IDLE_LEVEL_LOW;
|
||||
rmt_tx.tx_config.idle_output_en = true;
|
||||
rmt_tx_channel_config_t channelConfig = {};
|
||||
channelConfig.gpio_num = static_cast<gpio_num_t>(gpio);
|
||||
channelConfig.clk_src = RMT_CLK_SRC_DEFAULT;
|
||||
channelConfig.resolution_hz = RESOLUTION_HZ;
|
||||
channelConfig.mem_block_symbols = 64; // one block, refilled from the interrupt
|
||||
channelConfig.trans_queue_depth = 4;
|
||||
ESP_ERROR_CHECK(rmt_new_tx_channel(&channelConfig, &channel_));
|
||||
|
||||
ESP_ERROR_CHECK(rmt_config(&rmt_tx));
|
||||
ESP_ERROR_CHECK(rmt_driver_install(rmt_tx.channel, 0, 0));
|
||||
// Every byte goes out MSB first, one RMT symbol per bit - the strip's own byte order
|
||||
// (g, r, b, w) is already how LedStripRGBW lays the data out in memory.
|
||||
rmt_bytes_encoder_config_t encoderConfig = {};
|
||||
encoderConfig.bit0 = symbol(T0H, T0L);
|
||||
encoderConfig.bit1 = symbol(T1H, T1L);
|
||||
encoderConfig.flags.msb_first = 1;
|
||||
ESP_ERROR_CHECK(rmt_new_bytes_encoder(&encoderConfig, &encoder_));
|
||||
|
||||
rmt_translator_init((rmt_channel_t)rmtChannel, uint8ToRmtAdaptor);
|
||||
ESP_ERROR_CHECK(rmt_enable(channel_));
|
||||
}
|
||||
|
||||
void Esp32DriverRGBW::end()
|
||||
{
|
||||
ESP_ERROR_CHECK(rmt_driver_uninstall((rmt_channel_t)rmtChannel_));
|
||||
waitForTransmissionToFinish();
|
||||
ESP_ERROR_CHECK(rmt_disable(channel_));
|
||||
ESP_ERROR_CHECK(rmt_del_encoder(encoder_));
|
||||
ESP_ERROR_CHECK(rmt_del_channel(channel_));
|
||||
encoder_ = nullptr;
|
||||
channel_ = nullptr;
|
||||
}
|
||||
|
||||
void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds)
|
||||
{
|
||||
writeAsync(rgbwData, numLeds);
|
||||
waitForTransmissionToFinish();
|
||||
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
|
||||
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, true));
|
||||
}
|
||||
|
||||
void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds)
|
||||
{
|
||||
waitForTransmissionToFinish();
|
||||
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
|
||||
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, false));
|
||||
rmt_transmit_config_t transmitConfig = {};
|
||||
transmitConfig.loop_count = 0;
|
||||
ESP_ERROR_CHECK(rmt_transmit(channel_, encoder_, rgbwData, numLeds * 4, &transmitConfig));
|
||||
transmitting_ = true;
|
||||
}
|
||||
|
||||
@@ -96,8 +75,7 @@ bool Esp32DriverRGBW::waitForTransmissionToFinish(int waitMs)
|
||||
{
|
||||
if (!transmitting_)
|
||||
return true;
|
||||
auto ret = rmt_wait_tx_done((rmt_channel_t)rmtChannel_, waitMs / portTICK_PERIOD_MS);
|
||||
if (ret == ESP_OK)
|
||||
if (rmt_tx_wait_all_done(channel_, waitMs) == ESP_OK)
|
||||
{
|
||||
transmitting_ = false;
|
||||
return true;
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
#include "containers/LedStripRGBW.h"
|
||||
|
||||
#include <driver/rmt_tx.h>
|
||||
|
||||
class Esp32DriverRGBW
|
||||
{
|
||||
public:
|
||||
void begin(int gpio, int rmtChannel);
|
||||
// The RMT channel is picked by the driver; only the pin is ours to choose.
|
||||
void begin(int gpio);
|
||||
void end();
|
||||
|
||||
void writeSync(const uint32_t *rgbwData, int numLeds);
|
||||
@@ -16,8 +19,9 @@ public:
|
||||
bool waitForTransmissionToFinish(int waitMs = 1000);
|
||||
|
||||
private:
|
||||
int rmtChannel_;
|
||||
bool transmitting_;
|
||||
rmt_channel_handle_t channel_ = nullptr;
|
||||
rmt_encoder_handle_t encoder_ = nullptr;
|
||||
bool transmitting_ = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -9,6 +9,7 @@ enum class EffectId
|
||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||
REVERSE_SWIPE,
|
||||
STATIC_DETAILED,
|
||||
SEGMENT_SELECT,
|
||||
};
|
||||
|
||||
template <EffectId id>
|
||||
|
||||
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
@@ -0,0 +1,212 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/Common.h"
|
||||
#include "helpers/ColorRGBW.h"
|
||||
#include "helpers/ColorHSV.h"
|
||||
#include "helpers/ColorConversions.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
static constexpr int SEGMENT_SELECT_MAX_SEGMENTS = 6;
|
||||
|
||||
// The ring as a picker: one section per choice, the selected one bright and wobbling.
|
||||
//
|
||||
// The host re-sends this on every rotary detent. Each message builds a fresh effect, so
|
||||
// `previous` is what makes the highlight glide over from where it was instead of
|
||||
// jumping; previous == selected means no glide.
|
||||
#pragma pack(push, 1)
|
||||
struct EffectSegmentSelectConfig
|
||||
{
|
||||
uint8_t count; // number of sections, 1..SEGMENT_SELECT_MAX_SEGMENTS
|
||||
uint8_t selected; // index of the highlighted section
|
||||
uint8_t previous; // index the highlight glides from
|
||||
uint8_t gapLeds; // dark LEDs between neighbouring sections
|
||||
ColorRGBW colors[SEGMENT_SELECT_MAX_SEGMENTS];
|
||||
float offsetDegrees; // where section 0 starts
|
||||
int8_t direction; // +1 / -1: which way round the sections go
|
||||
float dimFactor; // brightness of the sections that are not selected, 0..1
|
||||
float wobbleAmplitude; // in LEDs
|
||||
float wobblePeriodMs;
|
||||
float slideMs; // time for the highlight to glide from `previous` to `selected`
|
||||
float edgeBlend; // 0..0.5: how far a section's edges shift towards its neighbours' hue
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
template <typename TLedStrip>
|
||||
class EffectSegmentSelect
|
||||
{
|
||||
public:
|
||||
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||
static constexpr int DELAY_MS = 10;
|
||||
using ConfigType = EffectSegmentSelectConfig;
|
||||
|
||||
EffectSegmentSelect(const EffectSegmentSelectConfig &cfg, TLedStrip &ledStrip)
|
||||
: config_(cfg),
|
||||
ledStrip_(ledStrip)
|
||||
{
|
||||
if (config_.count > SEGMENT_SELECT_MAX_SEGMENTS)
|
||||
config_.count = SEGMENT_SELECT_MAX_SEGMENTS;
|
||||
if (config_.count > 0)
|
||||
{
|
||||
config_.selected %= config_.count;
|
||||
config_.previous %= config_.count;
|
||||
}
|
||||
if (config_.direction == 0)
|
||||
config_.direction = 1;
|
||||
|
||||
pitch_ = float(NUM_LEDS) / float(config_.count > 0 ? config_.count : 1);
|
||||
halfWidth_ = std::max(0.5f, (pitch_ - float(config_.gapLeds)) / 2.0f);
|
||||
offsetLeds_ = config_.offsetDegrees / 360.0f * float(NUM_LEDS);
|
||||
|
||||
// The glide takes the short way round, so going from the last section to the first
|
||||
// moves one step forward rather than all the way back.
|
||||
int delta = int(config_.selected) - int(config_.previous);
|
||||
if (delta > config_.count / 2)
|
||||
delta -= config_.count;
|
||||
else if (delta < -(config_.count / 2))
|
||||
delta += config_.count;
|
||||
glideFrom_ = float(int(config_.selected) - delta);
|
||||
|
||||
// The dimmed background never changes, so it is computed once.
|
||||
for (int led = 0; led < NUM_LEDS; ++led)
|
||||
{
|
||||
const float u = ringPosition(led);
|
||||
const int section = std::min(int(u / pitch_), int(config_.count) - 1);
|
||||
ColorRGBW color{0, 0, 0, 0};
|
||||
if (config_.count > 0)
|
||||
{
|
||||
const float center = (float(section) + 0.5f) * pitch_;
|
||||
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(u - center));
|
||||
color = sectionColor(section, (u - center) / pitch_) * (coverage * config_.dimFactor);
|
||||
}
|
||||
background_[led] = color;
|
||||
}
|
||||
}
|
||||
|
||||
int operator()()
|
||||
{
|
||||
if (config_.count == 0)
|
||||
{
|
||||
clear(ledStrip_);
|
||||
return 10000;
|
||||
}
|
||||
|
||||
const float glide = config_.slideMs > 0 ? clamp01(elapsedMs_ / config_.slideMs) : 1.0f;
|
||||
const float eased = glide * glide * (3.0f - 2.0f * glide); // smoothstep
|
||||
const float index = glideFrom_ + (float(config_.selected) - glideFrom_) * eased;
|
||||
|
||||
float wobble = 0;
|
||||
if (config_.wobblePeriodMs > 0)
|
||||
wobble = config_.wobbleAmplitude * std::sin(2.0f * float(M_PI) * elapsedMs_ / config_.wobblePeriodMs);
|
||||
const float center = (index + 0.5f) * pitch_ + wobble;
|
||||
|
||||
for (int led = 0; led < NUM_LEDS; ++led)
|
||||
{
|
||||
const float distance = wrappedDistance(ringPosition(led) - center);
|
||||
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(distance));
|
||||
if (coverage <= 0)
|
||||
{
|
||||
setLedRGBW(ledStrip_, led, background_[led]);
|
||||
continue;
|
||||
}
|
||||
// The highlight carries the selected section's own gradient along with it, and
|
||||
// changes colour from the previous section's as it glides.
|
||||
const float local = distance / pitch_;
|
||||
const ColorRGBW from = sectionColor(config_.previous, local);
|
||||
const ColorRGBW to = sectionColor(config_.selected, local);
|
||||
const ColorRGBW highlight = ColorRGBW::interpolate(from, to, eased);
|
||||
setLedRGBW(ledStrip_, led, ColorRGBW::interpolate(background_[led], highlight, coverage));
|
||||
}
|
||||
|
||||
elapsedMs_ += DELAY_MS;
|
||||
return DELAY_MS;
|
||||
}
|
||||
|
||||
private:
|
||||
static float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
|
||||
|
||||
// An LED's position along the sections, 0..NUM_LEDS, section 0 starting at 0.
|
||||
float ringPosition(int led) const
|
||||
{
|
||||
float u = float(config_.direction) * (float(led) + 0.5f - offsetLeds_);
|
||||
u = std::fmod(u, float(NUM_LEDS));
|
||||
return u < 0 ? u + float(NUM_LEDS) : u;
|
||||
}
|
||||
|
||||
// Shortest signed distance on the ring.
|
||||
static float wrappedDistance(float d)
|
||||
{
|
||||
const float n = float(NUM_LEDS);
|
||||
d = std::fmod(d, n);
|
||||
if (d > n / 2)
|
||||
d -= n;
|
||||
else if (d < -n / 2)
|
||||
d += n;
|
||||
return d;
|
||||
}
|
||||
|
||||
// Colour of a section at `local`, -0.5 (its start) .. +0.5 (its end): its palette
|
||||
// colour in the middle, shifting in hue towards each neighbour by up to edgeBlend.
|
||||
ColorRGBW sectionColor(int section, float local) const
|
||||
{
|
||||
const int count = config_.count;
|
||||
const int neighbour = local < 0 ? (section + count - 1) % count : (section + 1) % count;
|
||||
const float amount = config_.edgeBlend * clamp01(std::fabs(local) * 2.0f);
|
||||
return blendHue(config_.colors[section], config_.colors[neighbour], amount);
|
||||
}
|
||||
|
||||
static ColorRGBW blendHue(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||
{
|
||||
if (f <= 0)
|
||||
return c1;
|
||||
ColorHSV h1 = rgb2hsv(c1);
|
||||
ColorHSV h2 = rgb2hsv(c2);
|
||||
// Greys have no hue of their own; borrow the other's so only saturation moves.
|
||||
if (h1.s <= 0)
|
||||
h1.h = h2.h;
|
||||
if (h2.s <= 0)
|
||||
h2.h = h1.h;
|
||||
float dh = h2.h - h1.h;
|
||||
if (dh > 180)
|
||||
dh -= 360;
|
||||
else if (dh < -180)
|
||||
dh += 360;
|
||||
float hue = h1.h + f * dh;
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
else if (hue >= 360)
|
||||
hue -= 360;
|
||||
ColorRGBW out = hsv2rgb(ColorHSV{hue, h1.s + f * (h2.s - h1.s), h1.v + f * (h2.v - h1.v)});
|
||||
out.w = uint8_t(float(c1.w) + f * (float(c2.w) - float(c1.w)));
|
||||
return out;
|
||||
}
|
||||
|
||||
EffectSegmentSelectConfig config_;
|
||||
TLedStrip &ledStrip_;
|
||||
float pitch_ = 0;
|
||||
float halfWidth_ = 0;
|
||||
float offsetLeds_ = 0;
|
||||
float glideFrom_ = 0;
|
||||
float elapsedMs_ = 0;
|
||||
ColorRGBW background_[NUM_LEDS];
|
||||
};
|
||||
|
||||
// Traits
|
||||
template <>
|
||||
struct EffectIdToConfig<EffectId::SEGMENT_SELECT>
|
||||
{
|
||||
using type = EffectSegmentSelectConfig;
|
||||
};
|
||||
|
||||
template <>
|
||||
struct EffectConfigToId<EffectSegmentSelectConfig>
|
||||
{
|
||||
static constexpr auto id = EffectId::SEGMENT_SELECT;
|
||||
};
|
||||
|
||||
template <typename TLedStrip>
|
||||
struct EffectIdToClass<EffectId::SEGMENT_SELECT, TLedStrip>
|
||||
{
|
||||
using type = EffectSegmentSelect<TLedStrip>;
|
||||
};
|
||||
@@ -220,12 +220,12 @@ esp_err_t rotary_encoder_init(rotary_encoder_info_t * info, gpio_num_t pin_a, gp
|
||||
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
|
||||
|
||||
// configure GPIOs
|
||||
gpio_pad_select_gpio(info->pin_a);
|
||||
gpio_reset_pin(info->pin_a);
|
||||
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
|
||||
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
|
||||
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
|
||||
|
||||
gpio_pad_select_gpio(info->pin_b);
|
||||
gpio_reset_pin(info->pin_b);
|
||||
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
|
||||
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
|
||||
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Put this into /etc/systemd/system/musicmouse.service
|
||||
[Unit]
|
||||
Description=Music Mouse RFID Music Player
|
||||
After=multi-user.target
|
||||
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -13,7 +13,9 @@ data_dir = data
|
||||
default_envs = esp32
|
||||
|
||||
[env:esp32]
|
||||
platform = espressif32
|
||||
; pioarduino's fork rather than PlatformIO's own espressif32 platform, which stays on
|
||||
; Arduino core 2.x. Pinned to a release: 55.03.312-1 is Arduino core 3.3.12 on ESP-IDF 5.5.
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312-1/platform-espressif32.zip
|
||||
board = esp-wrover-kit
|
||||
board_upload.flash_size = "4MB"
|
||||
build_flags = -DPLATFORM_ESP32
|
||||
@@ -21,14 +23,13 @@ framework = arduino
|
||||
monitor_port = /dev/ttyUSB0
|
||||
upload_port = /dev/ttyUSB0
|
||||
monitor_speed = 115200
|
||||
src_filter = +<*> -<host_test.cpp>
|
||||
build_src_filter = +<*> -<host_test.cpp>
|
||||
lib_deps =
|
||||
miguelbalboa/MFRC522
|
||||
bxparks/AceButton@^1.9.1
|
||||
bxparks/AceButton@1.10.1
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
src_filter = +<*> -<main.cpp>
|
||||
platform = native@1.2.1
|
||||
build_src_filter = +<*> -<main.cpp>
|
||||
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
||||
lib_deps =
|
||||
bxparks/AceButton@^1.9.1
|
||||
bxparks/AceButton@1.10.1
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "effects/ReverseSwipe.h"
|
||||
#include "effects/RandomTwoColorInterpolation.h"
|
||||
#include "effects/SwipeAndChange.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
|
||||
#include "Arduino.h"
|
||||
#include <cstdint>
|
||||
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
|
||||
|
||||
PREV_BUTTON_LED = 21,
|
||||
NEXT_BUTTON_LED = 22,
|
||||
|
||||
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
|
||||
};
|
||||
|
||||
template <>
|
||||
@@ -180,7 +183,7 @@ inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageH
|
||||
}
|
||||
|
||||
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
|
||||
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledChannelLeft, uint8_t ledChannelRight)
|
||||
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledPinLeft, uint8_t ledPinRight)
|
||||
{
|
||||
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
||||
return;
|
||||
@@ -214,6 +217,7 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
||||
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||
else if(handleLedEffect<EffectSegmentSelectConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {}
|
||||
|
||||
// Mouse LEDs
|
||||
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||
@@ -234,12 +238,12 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
||||
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
||||
{
|
||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||
ledcWrite(ledChannelLeft, uint32_t(255 * (*val)));
|
||||
ledcWrite(ledPinLeft, uint32_t(255 * (*val)));
|
||||
}
|
||||
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
|
||||
{
|
||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||
ledcWrite(ledChannelRight, uint32_t(255 * (*val)));
|
||||
ledcWrite(ledPinRight, uint32_t(255 * (*val)));
|
||||
}
|
||||
else
|
||||
Serial.println("Unknown message type");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "effects/Circular.h"
|
||||
#include "effects/Common.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
#include "drivers/Esp32DriverRGBW.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -82,6 +83,7 @@ void _led_task_func(void *params)
|
||||
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
// clang-format on
|
||||
|
||||
timeoutMsForEffect = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "effects/AlexaSwipe.h"
|
||||
#include "effects/ReverseSwipe.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
#include "helpers/ColorConversions.h"
|
||||
|
||||
#include <iostream>
|
||||
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
|
||||
effectToFile("reverse_swipe.py", effect, strip, 200);
|
||||
}
|
||||
|
||||
{
|
||||
EffectSegmentSelectConfig cfg{};
|
||||
cfg.count = 4;
|
||||
cfg.selected = 0;
|
||||
cfg.previous = 3;
|
||||
cfg.gapLeds = 1;
|
||||
cfg.colors[0] = ColorRGBW{255, 0, 0, 0};
|
||||
cfg.colors[1] = ColorRGBW{255, 160, 0, 0};
|
||||
cfg.colors[2] = ColorRGBW{0, 200, 0, 0};
|
||||
cfg.colors[3] = ColorRGBW{0, 60, 255, 0};
|
||||
cfg.offsetDegrees = 180;
|
||||
cfg.direction = 1;
|
||||
cfg.dimFactor = 0.15f;
|
||||
cfg.wobbleAmplitude = 1.5f;
|
||||
cfg.wobblePeriodMs = 1000;
|
||||
cfg.slideMs = 200;
|
||||
cfg.edgeBlend = 0.25f;
|
||||
LedStripRGBW<51> strip;
|
||||
|
||||
EffectSegmentSelect<decltype(strip)> effect(cfg, strip);
|
||||
effectToFile("segment_select.py", effect, strip, 200);
|
||||
}
|
||||
|
||||
/*
|
||||
effect.currentPosition_ = 150;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include <Arduino.h>
|
||||
#include "rc522.h"
|
||||
#include "SPI.h"
|
||||
#include <MFRC522.h>
|
||||
#include "rotary_encoder.h"
|
||||
|
||||
#include "containers/LedStripRGBW.h"
|
||||
@@ -21,9 +20,6 @@
|
||||
#include "TaskLed.h"
|
||||
|
||||
// -------------------------------------------------- RFID Reader ----------------------------------------
|
||||
MFRC522 rfid;
|
||||
MFRC522::MIFARE_Key key;
|
||||
|
||||
void tagHandler(uint8_t *sn)
|
||||
{
|
||||
if (sn != nullptr)
|
||||
@@ -117,11 +113,9 @@ void setupButtons()
|
||||
buttonRight.setEventHandler(handleButtonEvent);
|
||||
buttonRotary.setEventHandler(handleButtonEvent);
|
||||
|
||||
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttachPin(BUTTON_LEFT_LED_PIN, 0);
|
||||
|
||||
ledcSetup(1, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttachPin(BUTTON_RIGHT_LED_PIN, 1);
|
||||
// Arduino core 3 picks the LEDC channel itself; from here on the pin is the handle.
|
||||
ledcAttach(BUTTON_LEFT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttach(BUTTON_RIGHT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||
}
|
||||
|
||||
void handleButtons()
|
||||
@@ -139,7 +133,7 @@ LedTask<decltype(ledStripCircle)> ledTaskCircle;
|
||||
|
||||
void setupLedCircle()
|
||||
{
|
||||
ledDriverCircle.begin(22, 0);
|
||||
ledDriverCircle.begin(22);
|
||||
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
||||
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
||||
}
|
||||
@@ -152,7 +146,7 @@ LedTask<decltype(ledStripMouse)> ledTaskMouse;
|
||||
|
||||
void setupMouseLeds()
|
||||
{
|
||||
ledDriverMouse.begin(16, 1);
|
||||
ledDriverMouse.begin(16);
|
||||
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
||||
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||
}
|
||||
@@ -165,7 +159,7 @@ LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
||||
|
||||
void setupShelfLeds()
|
||||
{
|
||||
ledDriverShelf.begin(17, 2);
|
||||
ledDriverShelf.begin(17);
|
||||
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
||||
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||
}
|
||||
@@ -234,7 +228,7 @@ void setup()
|
||||
|
||||
void loop()
|
||||
{
|
||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1);
|
||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, BUTTON_LEFT_LED_PIN, BUTTON_RIGHT_LED_PIN);
|
||||
handleTouchInputs();
|
||||
handleRotaryEncoder();
|
||||
handleButtons();
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
#include <SPI.h>
|
||||
#include <MFRC522.h>
|
||||
|
||||
#define RST_PIN 5 // Configurable, see typical pin layout above
|
||||
#define SS_PIN 23 // Configurable, see typical pin layout above
|
||||
|
||||
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
|
||||
|
||||
//*****************************************************************************************//
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
SPI.begin(22, 19, 18, 23); // Init SPI bus
|
||||
mfrc522.PCD_Init(); // Init MFRC522 card
|
||||
}
|
||||
/**
|
||||
* Helper routine to dump a byte array as hex values to Serial.
|
||||
*/
|
||||
void PrintHex(byte *buffer, byte bufferSize) {
|
||||
for (byte i = 0; i < bufferSize; i++) {
|
||||
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
|
||||
Serial.print(buffer[i], HEX);
|
||||
}
|
||||
}
|
||||
|
||||
//*****************************************************************************************//
|
||||
|
||||
void loop() {
|
||||
|
||||
// Look for new cards
|
||||
if ( !mfrc522.PICC_IsNewCardPresent()) {
|
||||
return;
|
||||
}
|
||||
if ( !mfrc522.PICC_ReadCardSerial()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//mfrc522.PICC_DumpDetailsToSerial(&(mfrc522.uid)); //dump some details about the card
|
||||
|
||||
Serial.print("NewCard");
|
||||
PrintHex(mfrc522.uid.uidByte, mfrc522.uid.size);
|
||||
Serial.println("");
|
||||
|
||||
// Check if Card was removed
|
||||
bool cardRemoved = false;
|
||||
int counter = 0;
|
||||
bool current, previous;
|
||||
previous = !mfrc522.PICC_IsNewCardPresent();
|
||||
|
||||
while(!cardRemoved){
|
||||
current =!mfrc522.PICC_IsNewCardPresent();
|
||||
|
||||
if (current && previous) counter++;
|
||||
|
||||
previous = current;
|
||||
cardRemoved = (counter>2);
|
||||
delay(50);
|
||||
}
|
||||
|
||||
Serial.println("Card was removed");
|
||||
delay(500); //change value if you want to read cards faster
|
||||
mfrc522.PICC_HaltA();
|
||||
mfrc522.PCD_StopCrypto1();
|
||||
}
|
||||
5
python-backend/.gitignore
vendored
Normal file
5
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
config.yml
|
||||
tippen-curriculum.yml
|
||||
tippen-curriculum-dino.yml
|
||||
tippen-progress.json
|
||||
/.musicmouse-cache
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,434 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div>\n",
|
||||
"<a href=\"http://www.music-processing.de/\"><img style=\"float:left;\" src=\"../data/FMP_Teaser_Cover.png\" width=40% alt=\"FMP\"></a>\n",
|
||||
"<a href=\"https://www.audiolabs-erlangen.de\"><img src=\"../data/Logo_AudioLabs_Long.png\" width=59% style=\"float: right;\" alt=\"AudioLabs\"></a>\n",
|
||||
"</div>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div>\n",
|
||||
"<a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" width=\"100\" style=\"float:right;\" alt=\"C5\"></a>\n",
|
||||
"<h1>Hidden Markov Model (HMM)</h1> \n",
|
||||
"</div>\n",
|
||||
"\n",
|
||||
"<br/>\n",
|
||||
"\n",
|
||||
"<p>\n",
|
||||
"Motivated by the chord recognition problem, we give in this notebook an overview of hidden Markov models (HMMs) and introduce three famous algorithmic problems related with HMMs following Section 5.3 of <a href=\"http://www.music-processing.de/\">[Müller, FMP, Springer 2015]</a>. For a detailed introduction of HMMs, we refer to the famous tutorial paper by Rabiner.\n",
|
||||
"\n",
|
||||
"<ul>\n",
|
||||
"<li><span style=\"color:black\">\n",
|
||||
"Lawrence R. Rabiner: <strong>A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition.</strong> Proceedings of the IEEE, 77 (1989), pp. 257–286. \n",
|
||||
"<br> \n",
|
||||
"<a type=\"button\" class=\"btn btn-default btn-xs\" target=\"_blank\" href=\"../data/bibtex/FMP_bibtex_Rabiner89_HMM_IEEE.txt\"> Bibtex </a>\n",
|
||||
"</span></li>\n",
|
||||
"</ul>\n",
|
||||
"</p> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Markov Chains\n",
|
||||
"\n",
|
||||
"Certain transitions from one chord to another are more likely than others. To capture such likelihoods, one can employ a concept called **Markov chains**. Abstracting from our chord recognition scenario, we assume that the chord types to be considered are represented by a set \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\mathcal{A}:=\\{\\alpha_{1},\\alpha_{2},\\ldots,\\alpha_{I}\\}\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of size $I\\in\\mathbb{N}$. The elements $\\alpha_{i}$ for $i\\in[1:I]$ are referred to as **states**. A progression of chords is realized by a system that can be described at any time instance $n=1,2,3,\\ldots$ as being in some state $s_{n}\\in\\mathcal{A}$. The change from one state to another is specified according to a set of probabilities associated with each state. In general, a probabilistic description of such a system can be quite complex. To simplify the model, one often makes the assumption that the probability of a change from the current state $s_{n}$ to the next state $s_{n+1}$ only depends on the current state, and not on the events that preceded it. In terms of conditional probabilities, this property is expressed by\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i},s_{n-1}=\\alpha_{k},\\ldots]\n",
|
||||
" = P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i}].\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"The specific kind of \"amnesia\" is called the **Markov property**. Besides this property, one also often assumes that the system is **invariant under time shifts**, which means by definition that the following coefficients become independent of the index $n$:\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" a_{ij} := P[s_{n+1}=\\alpha_{j} | s_{n}=\\alpha_{i}] \\in [0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i,j\\in[1:I]$. These coefficients are also called **state transition probabilities**. They obey the standard stochastic constraint $\\sum_{j=1}^{I} a_{ij} = 1$ and can be expressed by an $(I\\times I)$ matrix, which we denote by $A$. A system that satisfies these properties is also called a (discrete-time) **Markov chain**. The following figure illustrates these definitions. It defines a Markov chain that consists of $I=3$ states $\\alpha_{1}$, $\\alpha_{2}$, and $\\alpha_{3}$, which correspond to the major chords $\\mathbf{C}$, $\\mathbf{G}$, and $\\mathbf{F}$, respectively. In the graph representation, the states correspond to the nodes, the transitions to the edges, and the transition probabilities to the labels attached to the edges. For example, the transition probability to remain in the state $\\alpha_{1}=\\mathbf{C}$ is $a_{11}=0.8$, whereas the transition probability of changing from $\\alpha_{1}=\\mathbf{C}$ to $\\alpha_{2}=\\mathbf{G}$ is $a_{12}=0.1$.\n",
|
||||
"\n",
|
||||
"<img src=\"../data/C5/FMP_C5_F24.png\" width=\"550px\" align=\"middle\" alt=\"FMP_C5_F24\">\n",
|
||||
"\n",
|
||||
"The model expresses the probability of all possible chord changes. To compute the probability of a given chord progression, one also needs the information on how the model gets started. This information is specified\n",
|
||||
"by additional model parameters referred to as **initial state probabilities**. For a general Markov chain, these probabilities are specified by the numbers\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" c_{i} := P[s_{1}=\\alpha_{i}] \\in [0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i\\in[1:I]$. These coefficients, which sum up to one, can be expressed by a vector of length $I$ denoted by $C$."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Hidden Markov Models\n",
|
||||
"\n",
|
||||
"Based on a Markov chain, one can compute a probability for a given observation consisting of a sequence of states or chord types. In our [chord recognition scenario](../C5/C5S2_ChordRec_Templates.html), however, this is not what we need. Rather than observing a sequence of chord types, we observe a **sequence of chroma vectors** that are somehow related to the chord types. In other words, the state sequence is not directly visible, but only a fuzzier observation sequence that is generated based on the state sequence. This leads to an extension of Markov chains to a statistical model referred to as a **hidden Markov model** (HMM). The idea is to represent the relation between the observed feature vectors and the chord types (the states) using a probabilistic framework. Each state is equipped with a probability function that expresses the likelihood for a given chord type to output or emit a certain feature vector. As a result, we obtain a two-layered process consisting of a **hidden layer** and an **observable layer**. The hidden layer produces a state sequence that is not observable (\"hidden\"), but generates the observation sequence on the basis of the state-dependent probability functions.\n",
|
||||
"\n",
|
||||
"The **first layer** of an HMM is a **Markov chain** as introduced above. To define the second layer of an HMM, we need to specify a space of possible output values and a probability function for each state. In general, the output space can be any set including the real numbers, a vector space, or any kind of feature space. For example, in the case of chord recognition, this space may be modeled as the feature space $\\mathcal{F}=\\mathbb{R}^{12}$ consisting of all possible $12$-dimensional chroma vectors. For the sake of simplicity, we only consider the case of a **discrete HMM**, where the output space is assumed to be discrete and even finite. In this case, the space can be modeled as a finite set \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\ldots,\\beta_{K}\\} \n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of size $K\\in\\mathbb{N}$ consisting of distinct output elements $\\beta_{k}$, $k\\in[1:K]$, which are also referred to as **observation symbols**. An HMM associates with each state a probability function, which is also referred to as the **emission probability** or **output probability**. In the discrete case, the emission probabilities are specified by coefficients\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" b_{ik}\\in[0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i\\in[1:I]$ and $k\\in[1:K]$. Each coefficient $b_{ik}$ expresses the probability of the system to output the observation symbol $\\beta_{k}$ when in state $\\alpha_{i}$. Similarly to the state transition probabilities, the emission probabilities are required to satisfy the stochastic constraint $\\sum_{k=1}^{K} \\beta_{ik} = 1$ for $i\\in[1:I]$ (thus forming a probability distribution for each state). The coefficients can be expressed by an $(I\\times K)$ matrix, which we denote by $B$. In summary, an HMM is specified by a tuple\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B).\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"The sets $\\mathcal{A}$ and $\\mathcal{B}$ are usually considered to be fixed components of the model, while the probability values specified by $A$, $B$, and $C$ are the free parameters to be determined. This can be done explicitly by an expert based on his or her musical knowledge or by employing a learning procedure based on suitably labeled training data. Continuing the above example, the following figure illustrates a hidden Markov model, where the state-dependent emission probabilities are indicated by the labels of the dashed arrows.\n",
|
||||
"\n",
|
||||
"<img src=\"../data/C5/FMP_C5_F25.png\" width=\"400px\" align=\"middle\" alt=\"FMP_C5_F25\">"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"In the following code cell, we define the state transition probability matrix $A$ and the output probability $B$ as specified by the figure. \n",
|
||||
"\n",
|
||||
"* Here, we assume that $\\alpha_{1}=\\mathbf{C}$, $\\alpha_{2}=\\mathbf{G}$, and $\\alpha_{3}=\\mathbf{F}$. \n",
|
||||
"* Furthermore, the elements of the output space $\\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\beta_{3}\\}$ represent the three chroma vectors ordered from left to right. \n",
|
||||
"* Finally, we assume that the initial state probability vector $C$ is given by the values $c_{1}=0.6$, $c_{2}=0.2$, $c_{3}=0.2$."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"from sklearn.preprocessing import normalize \n",
|
||||
"\n",
|
||||
"A = np.array([[0.8, 0.1, 0.1], \n",
|
||||
" [0.2, 0.7, 0.1], \n",
|
||||
" [0.1, 0.3, 0.6]])\n",
|
||||
"\n",
|
||||
"C = np.array([0.6, 0.2, 0.2])\n",
|
||||
"\n",
|
||||
"B = np.array([[0.7, 0.0, 0.3], \n",
|
||||
" [0.1, 0.9, 0.0], \n",
|
||||
" [0.0, 0.2, 0.8]])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## HMM-Based Sequence Generation \n",
|
||||
"\n",
|
||||
"Once an HMM is specified by $\\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B)$, it can be used for various analysis and synthesis applications. Since it is very instructive, we now discuss how to (artificially) generate, on the basis of a given HMM, an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$ of length $N\\in\\mathbb{N}$ with $o_n\\in \\mathcal{B}$, $n\\in[1:N]$. The generation procedure is as follows:\n",
|
||||
"\n",
|
||||
"1. Set $n=1$ and choose an initial state $s_n=\\alpha_i$ for some $i\\in[1:I]$ according to the initial state distribution $C$.\n",
|
||||
"2. Generate an observation $o_n=\\beta_k$ for some $k\\in[1:K]$ according to the emission probability in state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $B$).\n",
|
||||
"3. If $n=N$ then terminate the process. Otherwise, if $n<N$, transit to the new state $s_{n+1}=\\alpha_{j}$ according to the state transition probability at state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $A$). Then increase $n$ by one and return to step 2.\n",
|
||||
"\n",
|
||||
"In the next code cell, we implement this procedure and apply it to the example HMM specified above. Note that, due to Python conventions, we start in our implementation with index `0`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"n = 0, S[0] = 0, O[0] = 0\n",
|
||||
"n = 1, S[1] = 0, O[1] = 0\n",
|
||||
"n = 2, S[2] = 0, O[2] = 2\n",
|
||||
"n = 3, S[3] = 0, O[3] = 0\n",
|
||||
"n = 4, S[4] = 0, O[4] = 0\n",
|
||||
"n = 5, S[5] = 0, O[5] = 0\n",
|
||||
"n = 6, S[6] = 0, O[6] = 2\n",
|
||||
"n = 7, S[7] = 0, O[7] = 0\n",
|
||||
"n = 8, S[8] = 0, O[8] = 0\n",
|
||||
"n = 9, S[9] = 0, O[9] = 0\n",
|
||||
"State sequence S: [0 0 0 0 0 0 0 0 0 0]\n",
|
||||
"Observation sequence O: [0 0 2 0 0 0 2 0 0 0]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def generate_sequence_hmm(N, A, C, B, details=False):\n",
|
||||
" \"\"\"Generate observation and state sequence from given HMM\n",
|
||||
"\n",
|
||||
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" N (int): Number of observations to be generated\n",
|
||||
" A (np.ndarray): State transition probability matrix of dimension I x I\n",
|
||||
" C (np.ndarray): Initial state distribution of dimension I\n",
|
||||
" B (np.ndarray): Output probability matrix of dimension I x K\n",
|
||||
" details (bool): If \"True\" then shows details (Default value = False)\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" O (np.ndarray): Observation sequence of length N\n",
|
||||
" S (np.ndarray): State sequence of length N\n",
|
||||
" \"\"\"\n",
|
||||
" assert N > 0, \"N should be at least one\"\n",
|
||||
" I = A.shape[1]\n",
|
||||
" K = B.shape[1]\n",
|
||||
" assert I == A.shape[0], \"A should be an I-square matrix\"\n",
|
||||
" assert I == C.shape[0], \"Dimension of C should be I\"\n",
|
||||
" assert I == B.shape[0], \"Column-dimension of B should be I\"\n",
|
||||
"\n",
|
||||
" O = np.zeros(N, int)\n",
|
||||
" S = np.zeros(N, int)\n",
|
||||
" for n in range(N):\n",
|
||||
" if n == 0:\n",
|
||||
" i = np.random.choice(np.arange(I), p=C)\n",
|
||||
" else:\n",
|
||||
" i = np.random.choice(np.arange(I), p=A[i, :])\n",
|
||||
" k = np.random.choice(np.arange(K), p=B[i, :])\n",
|
||||
" S[n] = i\n",
|
||||
" O[n] = k\n",
|
||||
" if details:\n",
|
||||
" print('n = %d, S[%d] = %d, O[%d] = %d' % (n, n, S[n], n, O[n]))\n",
|
||||
" return O, S\n",
|
||||
"\n",
|
||||
"N = 10\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=True)\n",
|
||||
"print('State sequence S: ', S)\n",
|
||||
"print('Observation sequence O:', O)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"As a sanity check for the plausibility of our sequence generation approach, we now check if the generated sequences reflect well the probabilities of our HMM. To this end, we estimate the original transition probability matrix $A$ and the output probability matrix $B$ from a generated observation sequence $O$ and state sequence $S$.\n",
|
||||
"\n",
|
||||
"* To obtain an estimate of the entry $a_{ij}$ of $A$, we count all transitions from $n$ to $n+1$ with $S(n)=\\alpha_i$ and $S(n+1)=\\alpha_j$ and then divide this number by the total number of transitions starting with $\\alpha_i$.\n",
|
||||
"\n",
|
||||
"* Similarly, to obtain an estimate of the entry $b_{ik}$ of $B$, we count the number of occurrences $n$ with $S(n)=\\alpha_i$ and $O(n)=\\beta_k$ and divide this number by the total number of occurrences of $\\alpha_i$ in $S$.\n",
|
||||
"\n",
|
||||
"When generating longer sequences by increasing the number $N$, the resulting estimates should approach the original values in $A$ and $B$. This is demonstrated by the subsequent experiment. \n",
|
||||
"\n",
|
||||
"<div class=\"alert alert-block alert-warning\">\n",
|
||||
"Note: In practice, when estimating HMM model parameters from training data, only <strong>observation sequences</strong> are typically available, and the state sequences (that reflect the hidden generation process) are generally not known. Learning parameters only from observation sequences leads to much harder estimation problems as discussed below. \n",
|
||||
"</div> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"======== Estimation results when using N = 100 ========\n",
|
||||
"A =\n",
|
||||
"[[ 0.800 0.100 0.100]\n",
|
||||
" [ 0.200 0.700 0.100]\n",
|
||||
" [ 0.100 0.300 0.600]]\n",
|
||||
"A_est =\n",
|
||||
"[[ 0.795 0.091 0.114]\n",
|
||||
" [ 0.172 0.655 0.172]\n",
|
||||
" [ 0.154 0.269 0.577]]\n",
|
||||
"B =\n",
|
||||
"[[ 0.700 0.000 0.300]\n",
|
||||
" [ 0.100 0.900 0.000]\n",
|
||||
" [ 0.000 0.200 0.800]]\n",
|
||||
"B_est =\n",
|
||||
"[[ 0.705 0.000 0.295]\n",
|
||||
" [ 0.167 0.833 0.000]\n",
|
||||
" [ 0.000 0.423 0.577]]\n",
|
||||
"======== Estimation results when using N = 10000 ========\n",
|
||||
"A =\n",
|
||||
"[[ 0.800 0.100 0.100]\n",
|
||||
" [ 0.200 0.700 0.100]\n",
|
||||
" [ 0.100 0.300 0.600]]\n",
|
||||
"A_est =\n",
|
||||
"[[ 0.799 0.097 0.104]\n",
|
||||
" [ 0.198 0.696 0.106]\n",
|
||||
" [ 0.097 0.306 0.597]]\n",
|
||||
"B =\n",
|
||||
"[[ 0.700 0.000 0.300]\n",
|
||||
" [ 0.100 0.900 0.000]\n",
|
||||
" [ 0.000 0.200 0.800]]\n",
|
||||
"B_est =\n",
|
||||
"[[ 0.708 0.000 0.292]\n",
|
||||
" [ 0.103 0.897 0.000]\n",
|
||||
" [ 0.000 0.205 0.795]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def estimate_hmm_from_o_s(O, S, I, K):\n",
|
||||
" \"\"\"Estimate the state transition and output probability matrices from\n",
|
||||
" a given observation and state sequence\n",
|
||||
"\n",
|
||||
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" O (np.ndarray): Observation sequence of length N\n",
|
||||
" S (np.ndarray): State sequence of length N\n",
|
||||
" I (int): Number of states\n",
|
||||
" K (int): Number of observation symbols\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" A_est (np.ndarray): State transition probability matrix of dimension I x I\n",
|
||||
" B_est (np.ndarray): Output probability matrix of dimension I x K\n",
|
||||
" \"\"\"\n",
|
||||
" # Estimate A\n",
|
||||
" A_est = np.zeros([I, I])\n",
|
||||
" N = len(S)\n",
|
||||
" for n in range(N-1):\n",
|
||||
" i = S[n]\n",
|
||||
" j = S[n+1]\n",
|
||||
" A_est[i, j] += 1\n",
|
||||
" A_est = normalize(A_est, axis=1, norm='l1')\n",
|
||||
"\n",
|
||||
" # Estimate B\n",
|
||||
" B_est = np.zeros([I, K])\n",
|
||||
" for i in range(I):\n",
|
||||
" for k in range(K):\n",
|
||||
" B_est[i, k] = np.sum(np.logical_and(S == i, O == k))\n",
|
||||
" B_est = normalize(B_est, axis=1, norm='l1')\n",
|
||||
" return A_est, B_est\n",
|
||||
"\n",
|
||||
"N = 100\n",
|
||||
"print('======== Estimation results when using N = %d ========' % N)\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
|
||||
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
|
||||
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
|
||||
"print('A =', A, sep='\\n')\n",
|
||||
"print('A_est =', A_est, sep='\\n')\n",
|
||||
"print('B =', B, sep='\\n')\n",
|
||||
"print('B_est =', B_est, sep='\\n')\n",
|
||||
"\n",
|
||||
"N = 10000\n",
|
||||
"print('======== Estimation results when using N = %d ========' % N)\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
|
||||
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
|
||||
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
|
||||
"print('A =', A, sep='\\n')\n",
|
||||
"print('A_est =', A_est, sep='\\n')\n",
|
||||
"print('B =', B, sep='\\n')\n",
|
||||
"print('B_est =', B_est, sep='\\n')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"\n",
|
||||
"## Three Problems for HMMs\n",
|
||||
"\n",
|
||||
"We have seen how a given HMM can be used to generate an observation sequence. We will now look at three famous algorithmic problems for HMMs that concern the specification of the free model parameters and the evaluation of observation sequences. \n",
|
||||
"\n",
|
||||
"### 1. Evaluation Problem\n",
|
||||
"\n",
|
||||
"The first problem is known as **evaluation problem**. Given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$, the task is to compute the probability \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" P[O|\\Theta]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of the observation sequence given the model. From a slightly different viewpoint, this probability can be regarded as a score value that expresses how well a given model matches a given observation sequence. This interpretation becomes useful in the case where one is trying to choose among several competing models. The solution would then be to choose the model which best matches the observation sequence. To compute $P[O|\\Theta]$, we first consider a fixed state sequence $S=(s_1,s_2,\\ldots,s_N)$ of length $N$ with $s_n=\\alpha_{i_n}\\in\\mathcal{A}$ for some suitable $i_n\\in[1:I]$, $n\\in[1:N]$. The probability $P[O,S|\\Theta]$ for generating the state sequence $S$ as well as the observation sequence $O$ is given by \n",
|
||||
"\n",
|
||||
"$$\n",
|
||||
"P[O,S|\\Theta] = c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
|
||||
"$$\n",
|
||||
"\n",
|
||||
"Next, to obtain the overall probability $P[O|\\Theta]$, one needs to sum up all these probabilities considering all possible state sequences $S$ of length $|S|=N$:\n",
|
||||
"\n",
|
||||
"$$\n",
|
||||
"P[O|\\Theta] = \\sum_{S: |S|=N}P[O,S|\\Theta]\n",
|
||||
"= \\sum_{i_1=1}^I \\sum_{i_2=1}^I \\ldots \\sum_{i_N=1}^I\n",
|
||||
"c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
|
||||
"$$\n",
|
||||
"\n",
|
||||
"This leads to $I^N$ summands, a number that is exponential in the length $N$ of the observation sequence. Therefore, in practice, this brute-force calculation is computationally infeasible even for a small $N$. The good news is that there is a more efficient way to compute $P[O|\\Theta]$ using an algorithm that is based on the dynamic programming paradigm. This procedure, which is known as [**Forward–Backward Algorithm**](https://en.wikipedia.org/wiki/Forward%E2%80%93backward_algorithm), requires a number of operations on the order of $I^2N$ (instead of $I^N$). For a detailed description of this algorithm, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626).\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"### 2. Uncovering Problem\n",
|
||||
"\n",
|
||||
"The second problem is the so-called **uncovering problem**. Again, we are given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$. Instead of finding the overall probability $P[O|\\Theta]$ for $O$, where one needs to consider **all** possible state sequences, the goal of the uncovering problem is to find the **single** state sequence $S=(s_{1},s_{2},\\ldots,s_{N})$ that \"best explains\" the observation sequence. The uncovering problem stated so far is not well defined since, in general, there is not a single \"correct\" state sequence generating the observation sequence. Indeed, one needs a kind of optimization criterion that specifies what is meant when talking about a best possible explanation. There are several reasonable choices for such a criterion, and the actual choice will depend on the intended application. In the [FMP notebook on the Viterbi algorithm](../C5/C5S3_Viterbi.html), we will discuss one possible choice as well as an efficient algorithm (called **Viterbi algorithm**). This algorithm, which can be thought of as a kind of context-sensitive smoothing procedure, will apply in the [FMP notebook on HMM-based chord recognition](../C5/C5S3_ChordRec_HMM.html). \n",
|
||||
"\n",
|
||||
"\n",
|
||||
"### 3. Estimation Problem\n",
|
||||
"\n",
|
||||
"Besides the evaluation and uncovering problems, the third basic problem for HMMs is referred to as the **estimation problem**. Given an observation sequence $O$, the objective is to determine the free model parameters of $\\Theta$ (specified by by $A$, $C$, and $B$) that maximize the probability $P[O|\\Theta]$. In other words, the free model parameters are to be estimated so as to best describe the observation sequence. This is a typical instance of an **optimization problem** where a set of observation sequences serves as **training material** for adjusting or learning the HMM parameters. The estimation problem is by far the most difficult problem of HMMs. In fact, there is no known way to explicitly solve the given optimization problem. However, iterative procedures that find locally optimal solutions have been suggested. One of these procedures is known as the [**Baum–Welch Algorithm**](https://en.wikipedia.org/wiki/Baum%E2%80%93Welch_algorithm). Again, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626) for more details. "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div class=\"alert\" style=\"background-color:#F5F5F5; border-color:#C8C8C8\">\n",
|
||||
"<strong>Acknowledgment:</strong> This notebook was created by <a href=\"https://www.audiolabs-erlangen.de/fau/professor/mueller\">Meinard Müller</a>.\n",
|
||||
"</div> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<table style=\"border:none\">\n",
|
||||
"<tr style=\"border:none\">\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C0/C0.html\"><img src=\"../data/C0_nav.png\" style=\"height:50px\" alt=\"C0\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C1/C1.html\"><img src=\"../data/C1_nav.png\" style=\"height:50px\" alt=\"C1\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C2/C2.html\"><img src=\"../data/C2_nav.png\" style=\"height:50px\" alt=\"C2\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C3/C3.html\"><img src=\"../data/C3_nav.png\" style=\"height:50px\" alt=\"C3\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C4/C4.html\"><img src=\"../data/C4_nav.png\" style=\"height:50px\" alt=\"C4\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" style=\"height:50px\" alt=\"C5\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C6/C6.html\"><img src=\"../data/C6_nav.png\" style=\"height:50px\" alt=\"C6\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C7/C7.html\"><img src=\"../data/C7_nav.png\" style=\"height:50px\" alt=\"C7\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C8/C8.html\"><img src=\"../data/C8_nav.png\" style=\"height:50px\" alt=\"C8\"></a></td>\n",
|
||||
"</tr>\n",
|
||||
"</table>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"anaconda-cloud": {},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.8.10"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
259
python-backend/README.md
Normal file
259
python-backend/README.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# MusicMouse backend
|
||||
|
||||
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
||||
firmware over serial, plays music through VLC, drives three LED strips, indexes the
|
||||
music collection, and exposes everything to Home Assistant over MQTT and to a browser
|
||||
over HTTP.
|
||||
|
||||
```
|
||||
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
|
||||
browser ⇄ WebService ───────┘ │
|
||||
reactions/*.py ── call actions on ──► device / player
|
||||
```
|
||||
|
||||
Three objects own the outside world, one bus carries everything, and the *reactions*
|
||||
are the only place that decides what should happen. Adding a new way to control the
|
||||
mouse means adding a service that emits the same intents - no device or reaction
|
||||
changes. The web front-end was added exactly that way.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
pip install -e '.[dev]'
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
```
|
||||
|
||||
See `config.yml.example` for the schema and `musicmouse.service` for the systemd unit.
|
||||
Config problems are reported all at once with the path to each one; unknown keys are
|
||||
errors, not silent no-ops.
|
||||
|
||||
### On a host with no mouse attached
|
||||
|
||||
The web front-end is a complete way to drive the player, so the backend is useful on a
|
||||
machine with no serial port and no sound card worth grabbing. Two config keys say so,
|
||||
each by taking the literal value `simulate`:
|
||||
|
||||
| Setting | `simulate` gives you | Warned about as |
|
||||
|---|---|---|
|
||||
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
|
||||
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
|
||||
|
||||
Both keys are **required**. Leaving one out is a config error, not a shortcut to
|
||||
simulation - running blind or silent has to be asked for, so a config that lost a line
|
||||
fails loudly instead of booting into something that looks like it is working. The
|
||||
warnings are repeated on every boot for the same reason.
|
||||
|
||||
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
|
||||
whatever the desktop is playing through, which is the wrong thing to do silently - name
|
||||
a device (`"default"` is the system one) when the machine is meant to make noise.
|
||||
|
||||
### Without hardware
|
||||
|
||||
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
|
||||
if configured - against a fake serial link and a fake player.
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
```
|
||||
|
||||
```
|
||||
musicmouse> place fuchs
|
||||
in RfidTokenRead(04a1b2c3d4, figure='fuchs')
|
||||
rfid fuchs
|
||||
led ring: SwipeAndChange(AlexaSwipe(#(1.0, 0.4, 0.0, 0) -> ...))
|
||||
play playing
|
||||
musicmouse> press right
|
||||
play track 1: 01 - Song 1
|
||||
```
|
||||
|
||||
`help` lists the verbs. The same verbs go in a scenario file:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||
```
|
||||
|
||||
Scenario files run on a virtual clock under pytest, so `wait 1s` costs microseconds and
|
||||
every file in `scenarios/` is part of the test suite. A session reproduced by hand at
|
||||
the prompt becomes a regression test by pasting it into a `.txt` file.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `musicmouse/bus.py` | One FIFO queue on one loop. Thread-safe `emit()` - which is how libVLC's callback thread stops reaching the serial transport. |
|
||||
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
|
||||
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
|
||||
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
||||
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
||||
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
||||
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, parent-mode settings, the typing game and the Home Assistant proxy. |
|
||||
| `musicmouse/services/lirc/` | The IR remote, as a lircd TCP client. |
|
||||
| `musicmouse/services/` (`screen.py`, `timers.py`, `podcasts.py`) | The monitor's on/off and idle auto-off; the parents' timer and school-day calendar; the podcast feed poller. |
|
||||
| `musicmouse/tippen/` | The typing game's curriculum, progress and track unlocks. |
|
||||
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
||||
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
pytest # unit + scenario tests
|
||||
ruff check .
|
||||
mypy # --strict, configured in pyproject.toml
|
||||
```
|
||||
|
||||
`../scripts/check.sh` runs these three plus the web front-end's checks in one go.
|
||||
|
||||
`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python
|
||||
message ids drift from the firmware's - the contract is hand-duplicated in two
|
||||
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
|
||||
side). `tests/test_effects.py` pins the exact bytes of every effect payload.
|
||||
|
||||
## LED arbitration
|
||||
|
||||
`MusicMouseDevice` is the single writer to each zone, and the most recent effect wins -
|
||||
whether it came from a figure animation or from Home Assistant. There is no priority
|
||||
scheme. Every write emits `LedEffectChanged`, and the MQTT light entities publish their
|
||||
state from that rather than echoing their own commands, so HA keeps showing the strip's
|
||||
real state when a figure animation overrides a colour it set.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
The backend no longer calls Home Assistant directly (`hass-client` is gone). It
|
||||
publishes what happened; the automations live in HA.
|
||||
|
||||
MQTT device triggers are published for every button (`pressed`, `double_clicked`,
|
||||
`long_pressed`), every touch area (touched/released), and the RFID reader appears as a
|
||||
tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
|
||||
|
||||
### Recreating the old room-light behaviour
|
||||
|
||||
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
||||
|
||||
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
||||
trigger, action `light.toggle` on `light.kinderzimmer_fluter`. While the LED ring's album
|
||||
picker is open (a figure with several albums was just put down) the knob is the picker's:
|
||||
its turns do not change the volume and its press is not published.
|
||||
|
||||
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
||||
`light.music_mouse_regal_licht`:
|
||||
|
||||
| Touch area | Old service data |
|
||||
|---|---|
|
||||
| `right_foot` | `rgb_color: [235, 255, 67]` |
|
||||
| `left_foot` | `color_temp: 469` |
|
||||
| `right_ear` | `rgb_color: [101, 49, 255]` |
|
||||
| `left_ear` | `rgb_color: [255, 74, 254]` |
|
||||
|
||||
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
|
||||
that data.
|
||||
|
||||
## The library
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
|
||||
the scanner has to know about:
|
||||
|
||||
| Folder | Shown as | Why it is special |
|
||||
|---|---|---|
|
||||
| `Figuren/<figure>/` | an album per figurine - or, for one whose folder holds album folders instead of audio, up to six, picked on the LED ring | the folder name *is* the figure name from the config; a sub-album is titled `Fuchs - <folder>` |
|
||||
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||
|
||||
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
|
||||
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||
a playlist.
|
||||
|
||||
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
|
||||
backend becomes that podcast downloader itself: every six hours it checks the feed and
|
||||
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
|
||||
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
|
||||
as before - the file is the opt-in, there is no separate setting for it.
|
||||
|
||||
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||
them and the LED strips run the first of them, so shelf and screen agree.
|
||||
|
||||
### The cache
|
||||
|
||||
`general.library.cache` is a directory, not a file, because its contents cost wildly
|
||||
different amounts to produce:
|
||||
|
||||
```
|
||||
index.json cheap: tags and structure. Rebuilt freely.
|
||||
covers/<album_id>.jpg medium: the album's art, downscaled to 384 px
|
||||
covers/<album_id>.thumb.jpg medium: the same at 256 px, for cards and rows
|
||||
analysis/<track_key>.json expensive: track analysis (plus .beats.json, .curve.json)
|
||||
```
|
||||
|
||||
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||
of DSP per track, which is why anything expensive is keyed by *file content* rather than
|
||||
by album id - renaming a folder or re-sorting a section then costs nothing.
|
||||
|
||||
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
|
||||
change to how the scanner derives a title, artist or series is invisible until the cache
|
||||
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||
that logic.
|
||||
|
||||
**Track analysis** drives the web front-end's ambient background. It is optional: install
|
||||
the `analysis` extra (`pip install -e '.[analysis]'`, librosa and numpy) and
|
||||
`musicmouse/library/librosa_analyzer.py` works through the music shelf in the
|
||||
background, in a pool of niced worker processes (`musicmouse/library/workers.py`), and
|
||||
pauses while anything is playing. Without the extra, `build_analyzer()` returns a no-op
|
||||
and the background stays static. Scalars (`tempo`, `energy`, `valence`, `brightness`,
|
||||
`pulse`) travel inline with the index; the beat grid and per-second curves live in their
|
||||
own files and are fetched only for the playing track. Results are content-keyed, so they
|
||||
can be computed on a workstation and the `analysis/` folder copied to the device
|
||||
(`scripts/sync-library.sh` does that).
|
||||
|
||||
## The web front-end
|
||||
|
||||
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
||||
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
||||
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
||||
| `GET /api/state` | Snapshot: what is playing, where, how loud; plus `clock` - today's school day, the bus, the parents' timer. |
|
||||
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. |
|
||||
| `POST /api/play` | `{album_id, track_index?}` |
|
||||
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||
| `POST /api/volume` | `{percent}` or `{steps}` - the IR remote's step (`volume_increment` device units) per step |
|
||||
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
|
||||
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
|
||||
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |
|
||||
| `GET` `PUT /api/settings` | parent mode |
|
||||
| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it |
|
||||
| `GET /api/tracks/{album_id}/{index}/analysis` | beat grid and mood curves of one track, or 404 |
|
||||
| `GET /api/ha`, `GET /api/ha/states/{entity}`, `POST /api/ha/services/{domain}/{service}` | the room page's Home Assistant proxy; 404 without an `ha` section |
|
||||
| `GET /api/tippen/curriculum` `/progress`, `PUT /api/tippen/settings`, `POST /api/tippen/runs` | the typing game; 404 without a `tippen` section |
|
||||
|
||||
Two decisions worth knowing:
|
||||
|
||||
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
|
||||
there, which is what makes the design's type-to-search feel instant.
|
||||
|
||||
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
|
||||
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
|
||||
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
|
||||
encoder and the firmware carry on in device units.
|
||||
|
||||
### Parent mode
|
||||
|
||||
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
|
||||
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
|
||||
so the file keeps its comments, and a new ceiling applies to the running player rather
|
||||
than waiting for a restart.
|
||||
|
||||
This **hides** the settings; it does not protect them. There is no authentication on any
|
||||
endpoint, which matches a device on a home network - put it behind a reverse proxy if
|
||||
that is not good enough.
|
||||
@@ -1,128 +0,0 @@
|
||||
import librosa
|
||||
from numba import jit
|
||||
import numpy as np
|
||||
|
||||
|
||||
@jit(nopython=True)
|
||||
def normalize_feature_sequence(X, norm='2', threshold=0.0001, v=None):
|
||||
"""Normalizes the columns of a feature sequence
|
||||
Notebook: C3/C3S1_FeatureNormalization.ipynb
|
||||
Args:
|
||||
X (np.ndarray): Feature sequence
|
||||
norm (str): The norm to be applied. '1', '2', 'max' or 'z' (Default value = '2')
|
||||
threshold (float): An threshold below which the vector ``v`` used instead of normalization
|
||||
(Default value = 0.0001)
|
||||
v (float): Used instead of normalization below ``threshold``. If None, uses unit vector for given norm
|
||||
(Default value = None)
|
||||
Returns:
|
||||
X_norm (np.ndarray): Normalized feature sequence
|
||||
"""
|
||||
assert norm in ['1', '2', 'max', 'z']
|
||||
|
||||
K, N = X.shape
|
||||
X_norm = np.zeros((K, N))
|
||||
|
||||
if norm == '1':
|
||||
if v is None:
|
||||
v = np.ones(K, dtype=np.float64) / K
|
||||
for n in range(N):
|
||||
s = np.sum(np.abs(X[:, n]))
|
||||
if s > threshold:
|
||||
X_norm[:, n] = X[:, n] / s
|
||||
else:
|
||||
X_norm[:, n] = v
|
||||
|
||||
if norm == '2':
|
||||
if v is None:
|
||||
v = np.ones(K, dtype=np.float64) / np.sqrt(K)
|
||||
for n in range(N):
|
||||
s = np.sqrt(np.sum(X[:, n]**2))
|
||||
if s > threshold:
|
||||
X_norm[:, n] = X[:, n] / s
|
||||
else:
|
||||
X_norm[:, n] = v
|
||||
|
||||
if norm == 'max':
|
||||
if v is None:
|
||||
v = np.ones(K, dtype=np.float64)
|
||||
for n in range(N):
|
||||
s = np.max(np.abs(X[:, n]))
|
||||
if s > threshold:
|
||||
X_norm[:, n] = X[:, n] / s
|
||||
else:
|
||||
X_norm[:, n] = v
|
||||
|
||||
if norm == 'z':
|
||||
if v is None:
|
||||
v = np.zeros(K, dtype=np.float64)
|
||||
for n in range(N):
|
||||
mu = np.sum(X[:, n]) / K
|
||||
sigma = np.sqrt(np.sum((X[:, n] - mu)**2) / (K - 1))
|
||||
if sigma > threshold:
|
||||
X_norm[:, n] = (X[:, n] - mu) / sigma
|
||||
else:
|
||||
X_norm[:, n] = v
|
||||
|
||||
return X_norm
|
||||
|
||||
|
||||
def compute_chromagram_from_filename(fn_wav,
|
||||
Fs=22050,
|
||||
N=4096,
|
||||
H=2048,
|
||||
gamma=None,
|
||||
version='STFT',
|
||||
norm='2'):
|
||||
"""Compute chromagram for WAV file specified by filename
|
||||
|
||||
Notebook: C5/C5S2_ChordRec_Templates.ipynb
|
||||
|
||||
Args:
|
||||
fn_wav (str): Filenname of WAV
|
||||
Fs (scalar): Sampling rate (Default value = 22050)
|
||||
N (int): Window size (Default value = 4096)
|
||||
H (int): Hop size (Default value = 2048)
|
||||
gamma (float): Constant for logarithmic compression (Default value = None)
|
||||
version (str): Technique used for front-end decomposition ('STFT', 'IIS', 'CQT') (Default value = 'STFT')
|
||||
norm (str): If not 'None', chroma vectors are normalized by norm as specified ('1', '2', 'max')
|
||||
(Default value = '2')
|
||||
|
||||
Returns:
|
||||
X (np.ndarray): Chromagram
|
||||
Fs_X (scalar): Feature reate of chromagram
|
||||
x (np.ndarray): Audio signal
|
||||
Fs (scalar): Sampling rate of audio signal
|
||||
x_dur (float): Duration (seconds) of audio signal
|
||||
"""
|
||||
x, Fs = librosa.load(fn_wav, sr=Fs)
|
||||
x_dur = x.shape[0] / Fs
|
||||
if version == 'STFT':
|
||||
# Compute chroma features with STFT
|
||||
X = librosa.stft(x, n_fft=N, hop_length=H, pad_mode='constant', center=True)
|
||||
if gamma is not None:
|
||||
X = np.log(1 + gamma * np.abs(X)**2)
|
||||
else:
|
||||
X = np.abs(X)**2
|
||||
X = librosa.feature.chroma_stft(S=X, sr=Fs, tuning=0, norm=None, hop_length=H, n_fft=N)
|
||||
if version == 'CQT':
|
||||
# Compute chroma features with CQT decomposition
|
||||
X = librosa.feature.chroma_cqt(y=x, sr=Fs, hop_length=H, norm=None)
|
||||
if version == 'IIR':
|
||||
# Compute chroma features with filter bank (using IIR elliptic filter)
|
||||
X = librosa.iirt(y=x, sr=Fs, win_length=N, hop_length=H, center=True, tuning=0.0)
|
||||
if gamma is not None:
|
||||
X = np.log(1.0 + gamma * X)
|
||||
X = librosa.feature.chroma_cqt(C=X,
|
||||
bins_per_octave=12,
|
||||
n_octaves=7,
|
||||
fmin=librosa.midi_to_hz(24),
|
||||
norm=None)
|
||||
if norm is not None:
|
||||
X = normalize_feature_sequence(X, norm='2')
|
||||
Fs_X = Fs / H
|
||||
return X, Fs_X, x, Fs, x_dur
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_file = "/home/martin/Music/deemix Music/Simone Sommerland - Ki-Ka-Kinderturnen.mp3"
|
||||
chroma = compute_chromagram_from_filename(test_file)
|
||||
@@ -1,51 +1,246 @@
|
||||
# Example config.yml for the MusicMouse python-backend.
|
||||
# Example config for the MusicMouse backend.
|
||||
#
|
||||
# Reverse-engineered from main.py (load_config/Controller) since no schema
|
||||
# was previously documented. Copy this file to config.yml in the directory
|
||||
# passed as the CLI argument to main.py, e.g.:
|
||||
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||
#
|
||||
# python main.py /media/musicmouse/
|
||||
#
|
||||
# main.py reads "<config_dir>/config.yml". Real credentials (hass_token,
|
||||
# mqtt.password) should never be committed - keep the real config.yml
|
||||
# outside the repo (e.g. only on the deployed device).
|
||||
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||
# Keep the real config (with credentials) off the repo - on the device only.
|
||||
|
||||
general:
|
||||
# ALSA output device passed to python-vlc, e.g. "hw:0,0"; omit/null for VLC's default.
|
||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||
# settings, because each one has its own quirks the code already knows about:
|
||||
#
|
||||
# <root>/Figuren/<figure name>/ one folder per figurine - or, for a figure
|
||||
# with several albums, one subfolder each
|
||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
#
|
||||
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||
# of the files' tags. Relative paths resolve against this file's directory.
|
||||
library:
|
||||
root: /home/martin/Music
|
||||
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||
# is expensive to recompute.
|
||||
cache: .musicmouse-cache
|
||||
# How many tracks the background analyzer may work on at once, each in its own
|
||||
# worker process. Omitted means one per core bar one (capped at 8), which is what
|
||||
# turns a first-time pass over a whole library from an overnight job into a coffee
|
||||
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
|
||||
# a specific number to cap how much of it analysis may take.
|
||||
# analysis_workers: 4
|
||||
|
||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
baudrate: 115200
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||
# commandeer. Startup says so every boot.
|
||||
#
|
||||
# Both of these are required rather than optional on purpose: running blind or silent
|
||||
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||
# into something that looks like it is working.
|
||||
alsa_device: "softvol_effects"
|
||||
|
||||
# Serial port the ESP32 firmware is connected on.
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||
min_volume: 0
|
||||
max_volume: 60
|
||||
initial_volume: 40
|
||||
volume_increment: 5 # per rotary-encoder click
|
||||
|
||||
# Home Assistant connection used for light/service calls (hass_service()).
|
||||
hass_url: "http://homeassistant.local:8123"
|
||||
hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN"
|
||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||
button_leds_brightness: 0.5
|
||||
|
||||
# MQTT broker used for the Home-Assistant-discoverable "shelf light".
|
||||
# Loudness of the UI sounds - the screen's on/off blubb, the parents' timer, the
|
||||
# album picker's clicks - in percent of how loud they are made to sit under the
|
||||
# music. They follow the music's volume, but never drop below what they are at 30 %
|
||||
# of it. Editable in parent mode.
|
||||
effects_volume: 100
|
||||
|
||||
# The LED ring's album picker, for a figure with several albums (see `figures:`).
|
||||
# Temporary: for tuning on the device. Every value defaults to a constant in
|
||||
# musicmouse/reactions/selection.py, which is where the right ones belong once found.
|
||||
# figure_selection:
|
||||
# timeout: 8.0 # seconds without a turn before the picker closes
|
||||
# offset_degrees: 180 # where the first album's section starts on the ring
|
||||
# ring_direction: 1 # 1 or -1: which way round the sections go
|
||||
# knob_direction: 1 # 1 or -1: which way the knob turns to the next album
|
||||
# gap_leds: 1 # dark LEDs between sections
|
||||
# dim_factor: 0.15 # brightness of the sections not selected, 0..1
|
||||
# wobble_amplitude: 1.5 # how far the selected section swings, in LEDs
|
||||
# wobble_period_ms: 1000 # one swing back and forth
|
||||
# slide_ms: 200 # glide from one section to the next
|
||||
# edge_blend: 0.25 # 0..0.5: how far section edges shift to their neighbours' hue
|
||||
|
||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||
# a half-finished .tmp - is ignored.
|
||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||
|
||||
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||
#
|
||||
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||
# re-download the episodes the last one deleted.
|
||||
#
|
||||
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||
# keep every episode and mind the free space yourself.
|
||||
podcast_episode_limit: 50
|
||||
|
||||
# The web front-end. Omit the whole section to run without it.
|
||||
#
|
||||
# There is no authentication: this is a device on a home network. The settings panel
|
||||
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||
static_dir: ../web/dist
|
||||
|
||||
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
||||
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||
# number keys 0-9 play whatever the "remote:" section below assigns them; Guide
|
||||
# (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
|
||||
# button that does nothing.
|
||||
lirc:
|
||||
host: "musicmouse-pi.local"
|
||||
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||
# default of 8765 - see the ansible role
|
||||
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||
# remote, say) are ignored (case-insensitively)
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# The attached monitor, switched by the remote's power key. Omit the section on a
|
||||
# device without one and the key does nothing. The command runs on every press, split
|
||||
# shell-style but not through a shell; it decides itself whether that means on or off
|
||||
# (ansible's pi_kiosk installs kiosk-screen for this).
|
||||
#
|
||||
# With an off_command the screen also switches itself off once nothing has played and
|
||||
# nobody has touched the remote, the web UI or the kiosk keyboard for auto_off_minutes
|
||||
# (editable in parent mode; 0 = never). The next press after that runs on_command.
|
||||
# screen:
|
||||
# toggle_command: "/usr/local/bin/kiosk-screen toggle"
|
||||
# off_command: "/usr/local/bin/kiosk-screen off"
|
||||
# on_command: "/usr/local/bin/kiosk-screen on"
|
||||
# auto_off_minutes: 15
|
||||
# timeout: 5.0 # seconds before a hung command is killed
|
||||
|
||||
# The clock overlay (Pos1 on the kiosk keyboard, Guide on the remote). Optional; these
|
||||
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before
|
||||
# bus_time. School days skip weekends and the school and public holidays of
|
||||
# `subdivision`, fetched daily from openholidaysapi.org and cached under
|
||||
# library.cache - a city code such as DE-BY-AU adds that city's own holidays.
|
||||
# clock:
|
||||
# subdivision: "DE-BY"
|
||||
# bus_time: "07:25"
|
||||
# bus_lead_minutes: 60
|
||||
|
||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||
mqtt:
|
||||
server: "homeassistant.local"
|
||||
port: 1883
|
||||
user: "musicmouse"
|
||||
password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||
base_topic: "musicmouse"
|
||||
discovery_prefix: "homeassistant"
|
||||
device_id: "musicmouse"
|
||||
device_name: "Music Mouse"
|
||||
reconnect_interval: 10.0
|
||||
|
||||
# Optional playback/UI tuning (all have defaults if omitted).
|
||||
min_volume: 0
|
||||
max_volume: 32
|
||||
volume_increment: 5 # per rotary-encoder tick
|
||||
button_leds_brightness: 0.5 # 0..1, brightness of the prev/next button backlight
|
||||
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||
# entities, not the other way round. The backend proxies every call to Home
|
||||
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||
ha:
|
||||
url: "http://homeassistant.local:8123"
|
||||
# A long-lived access token, created under the HA user's own profile page.
|
||||
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||
# entity id if omitted.
|
||||
devices:
|
||||
- entity_id: cover.kinderzimmer_rollo
|
||||
name: "Rollo"
|
||||
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||
name: "Hue Beyond links"
|
||||
- entity_id: light.kinderzimmer_deckenlampe
|
||||
name: "Deckenlampe"
|
||||
# Scene pill row above the cards, in this order.
|
||||
scenes:
|
||||
- entity_id: scene.kinderzimmer_lesen
|
||||
name: "Lesen"
|
||||
- entity_id: scene.kinderzimmer_gute_nacht
|
||||
name: "Gute Nacht"
|
||||
|
||||
# One entry per figurine. The key is an arbitrary figure name (also used as
|
||||
# the subdirectory name under the config dir when media_files is omitted).
|
||||
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
|
||||
# front-end. The lesson plan is content, not device config, so it lives in its own
|
||||
# file - see tippen-curriculum.yml.example for the format, including the optional
|
||||
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
|
||||
tippen:
|
||||
curriculum_file: tippen-curriculum.yml
|
||||
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
|
||||
# app itself - never hand-edited. Relative to this file, like curriculum_file.
|
||||
progress_file: tippen-progress.json
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
#
|
||||
# A figure folder that holds audio itself plays that. One that holds album folders
|
||||
# instead can play any of them, up to six: putting the figure down plays the one it
|
||||
# played last (or the first, alphabetically), and the LED ring shows one coloured
|
||||
# section per album for a few seconds. Turn the knob to move between them and press it
|
||||
# to play the selected one; leave it alone and the music just carries on.
|
||||
figures:
|
||||
fuchs:
|
||||
# RFID tag id as a hex string (matched against bytes read from the reader).
|
||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||
id: "04a1b2c3d4"
|
||||
# Exactly 4 colors: [primary, secondary, background, accent].
|
||||
# Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff").
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"]
|
||||
# Optional explicit list of media file paths for this figure's playlist.
|
||||
# If omitted, main.py globs os.path.join(config_dir, "<figure_name>").
|
||||
media_files: []
|
||||
# Exactly four colours: primary, secondary, background, accent.
|
||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||
# one thing that has to be said out loud. The web UI draws albums square and
|
||||
# audiobooks taller than wide, so getting it wrong is visible at a glance. It holds
|
||||
# for every album of a figure that has several.
|
||||
kind: music
|
||||
|
||||
eule:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
kind: book
|
||||
|
||||
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
|
||||
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
||||
# and that is not an error. Editable from the web front-end, which writes back here.
|
||||
#
|
||||
# target_kind: album -> starts from the first track (music, audiobooks), or from
|
||||
# track_index (0-based) when given - one song, followed by
|
||||
# the rest of the album. target is an album id, as shown
|
||||
# at GET /api/library.
|
||||
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||
# fresh on every press - never a fixed episode. target is the
|
||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||
remote:
|
||||
"1":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
"2":
|
||||
target_kind: series
|
||||
target: "Wissen macht Ah"
|
||||
"3":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
track_index: 4
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Some simple tests/examples for the Home Assistant client."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from hass_client import HomeAssistantClient
|
||||
|
||||
LOGGER = logging.getLogger()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
logformat = logging.Formatter(
|
||||
"%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s")
|
||||
consolehandler = logging.StreamHandler()
|
||||
consolehandler.setFormatter(logformat)
|
||||
LOGGER.addHandler(consolehandler)
|
||||
LOGGER.setLevel(logging.DEBUG)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
LOGGER.error("usage: test.py <url> <token>")
|
||||
sys.exit()
|
||||
|
||||
url = sys.argv[1]
|
||||
token = sys.argv[2]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
hass = HomeAssistantClient(url, token)
|
||||
|
||||
async def hass_event(event, event_details):
|
||||
"""Handle hass event callback."""
|
||||
LOGGER.info("received event %s --> %s\n", event, event_details)
|
||||
|
||||
hass.register_event_callback(hass_event)
|
||||
|
||||
async def run():
|
||||
"""Run tests."""
|
||||
await hass.async_connect()
|
||||
await asyncio.sleep(10)
|
||||
await hass.async_close()
|
||||
loop.stop()
|
||||
|
||||
try:
|
||||
loop.create_task(run())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
loop.close()
|
||||
@@ -1,200 +0,0 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
import struct
|
||||
|
||||
from led_cmds import (EffectStaticConfig, EffectStaticDetailedConfig, EffectAlexaSwipeConfig,
|
||||
EffectCircularConfig, EffectRandomTwoColorInterpolationConfig,
|
||||
EffectSwipeAndChange, EffectReverseSwipe)
|
||||
|
||||
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
|
||||
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
|
||||
|
||||
|
||||
class MessageFwToHost(Enum):
|
||||
RFID_TOKEN_READ = 0
|
||||
ROTARY_ENCODER = 1
|
||||
TOUCH_BUTTON_PRESS = 2
|
||||
TOUCH_BUTTON_RELEASE = 3
|
||||
|
||||
|
||||
class TouchButton(Enum):
|
||||
LEFT_FOOT = 0
|
||||
RIGHT_FOOT = 1
|
||||
LEFT_EAR = 2
|
||||
RIGHT_EAR = 3
|
||||
|
||||
|
||||
led_ring_effect_to_message_id = {
|
||||
EffectStaticConfig: 0,
|
||||
EffectAlexaSwipeConfig: 1,
|
||||
EffectCircularConfig: 2,
|
||||
EffectRandomTwoColorInterpolationConfig: 3,
|
||||
EffectSwipeAndChange: 4,
|
||||
EffectReverseSwipe: 5,
|
||||
}
|
||||
|
||||
mouse_led_effect_to_message_id = {
|
||||
EffectStaticConfig: 6,
|
||||
EffectCircularConfig: 7,
|
||||
EffectRandomTwoColorInterpolationConfig: 8,
|
||||
EffectSwipeAndChange: 9,
|
||||
EffectReverseSwipe: 10,
|
||||
}
|
||||
|
||||
shelve_led_effect_to_message_id = {
|
||||
EffectStaticConfig: 15,
|
||||
EffectCircularConfig: 16,
|
||||
EffectRandomTwoColorInterpolationConfig: 17,
|
||||
EffectSwipeAndChange: 18,
|
||||
EffectReverseSwipe: 19,
|
||||
EffectStaticDetailedConfig: 20,
|
||||
}
|
||||
|
||||
mouse_leds_index_ranges = {
|
||||
TouchButton.RIGHT_FOOT: (0, 6),
|
||||
TouchButton.LEFT_FOOT: (6, 6 + 6),
|
||||
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
|
||||
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
|
||||
}
|
||||
|
||||
PREV_BUTTON_LED_MSG = 21
|
||||
NEXT_BUTTON_LED_MSG = 22
|
||||
|
||||
|
||||
class RfidTokenRead:
|
||||
def __init__(self, id: bytes):
|
||||
self.id = id
|
||||
|
||||
def __repr__(self):
|
||||
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
|
||||
|
||||
|
||||
class RotaryEncoderEvent:
|
||||
def __init__(self, msg_content: bytes):
|
||||
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
|
||||
|
||||
|
||||
class TouchButtonPress:
|
||||
def __init__(self, msg_content: bytes):
|
||||
val = int(msg_content[0])
|
||||
self.touch_button = TouchButton(val)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Pressed " + repr(self.touch_button)
|
||||
|
||||
|
||||
class TouchButtonRelease:
|
||||
def __init__(self, msg_content: bytes):
|
||||
val = int(msg_content[0])
|
||||
self.touch_button = TouchButton(val)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Released " + repr(self.touch_button)
|
||||
|
||||
|
||||
class ButtonEvent:
|
||||
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
|
||||
event_name = {
|
||||
0: 'pressed',
|
||||
1: 'released',
|
||||
2: 'clicked',
|
||||
3: 'double_clicked',
|
||||
4: 'long_pressed',
|
||||
5: 'repeat_pressed',
|
||||
6: 'long_released'
|
||||
}
|
||||
|
||||
def __init__(self, msg_content: bytes):
|
||||
button_nr, event_nr = struct.unpack("<BB", msg_content)
|
||||
self.button = self.button_name[button_nr]
|
||||
self.event = self.event_name[event_nr]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Button {self.button} {self.event}"
|
||||
|
||||
|
||||
incomingMsgMap = {
|
||||
0: RfidTokenRead,
|
||||
1: RotaryEncoderEvent,
|
||||
2: TouchButtonPress,
|
||||
3: TouchButtonRelease,
|
||||
4: ButtonEvent,
|
||||
}
|
||||
|
||||
|
||||
class MusicMouseProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
super()
|
||||
self._msg_callback = None
|
||||
|
||||
def register_message_callback(self, cb):
|
||||
self._msg_callback = cb
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
self.in_buff = bytes()
|
||||
|
||||
def __led_effect(self, effect_cfg, msg_dict):
|
||||
msg_content = effect_cfg.as_bytes()
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
|
||||
len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def led_ring_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
|
||||
|
||||
def mouse_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
|
||||
|
||||
def shelve_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
|
||||
|
||||
def button_background_led_prev(self, val):
|
||||
msg_content = struct.pack("<f", val)
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def button_background_led_next(self, val):
|
||||
msg_content = struct.pack("<f", val)
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def data_received(self, data):
|
||||
self.in_buff += data
|
||||
self._parse_message()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
print('port closed')
|
||||
self.transport.loop.stop()
|
||||
|
||||
def pause_writing(self):
|
||||
print('pause writing')
|
||||
print(self.transport.get_write_buffer_size())
|
||||
|
||||
def resume_writing(self):
|
||||
print(self.transport.get_write_buffer_size())
|
||||
print('resume writing')
|
||||
|
||||
def _parse_message(self):
|
||||
HEADER_SIZE = 4 + 1 + 2
|
||||
if len(self.in_buff) == 0:
|
||||
return
|
||||
if len(self.in_buff) >= HEADER_SIZE:
|
||||
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
|
||||
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
|
||||
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
|
||||
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
|
||||
else:
|
||||
idx = self.in_buff.find("\n".encode())
|
||||
if idx >= 0:
|
||||
text_msg = self.in_buff[:idx]
|
||||
print("LOG:", text_msg.decode())
|
||||
self.in_buff = self.in_buff[idx + 1:]
|
||||
|
||||
def _on_msg_receive(self, msg_type, msg_payload):
|
||||
parsed_msg = incomingMsgMap[msg_type](msg_payload)
|
||||
if self._msg_callback is not None:
|
||||
self._msg_callback(self, parsed_msg)
|
||||
@@ -1,161 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
import struct
|
||||
import colorsys
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorRGBW:
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
w: float
|
||||
|
||||
def __repr__(self):
|
||||
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
assert self.is_valid(), "Trying to send invalid " + repr(self)
|
||||
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
|
||||
int(self.w * 255))
|
||||
|
||||
def is_valid(self):
|
||||
vals = (self.r, self.g, self.b, self.w)
|
||||
return all(0 <= v <= 1 for v in vals)
|
||||
|
||||
def __mul__(self, other:float):
|
||||
assert 0<= other <= 1
|
||||
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
|
||||
|
||||
def without_white_channel(self, scale=1):
|
||||
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
|
||||
return ColorRGBW(*args, 0)
|
||||
|
||||
@dataclass
|
||||
class ColorHSV:
|
||||
h: float
|
||||
s: float
|
||||
v: float
|
||||
|
||||
@staticmethod
|
||||
def fromRGB(rgb):
|
||||
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
||||
return ColorHSV(conv[0] * 360, conv[1], conv[2])
|
||||
|
||||
def __repr__(self):
|
||||
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<fff", self.h, self.s, self.v)
|
||||
|
||||
def is_valid(self):
|
||||
if not 0 <= self.h <= 360:
|
||||
return False
|
||||
if not 0 <= self.s <= 1:
|
||||
return False
|
||||
if not 0 <= self.v <= 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticConfig:
|
||||
color: ColorRGBW
|
||||
begin: int = 0
|
||||
end: int = 0
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticDetailedConfig:
|
||||
color: ColorRGBW
|
||||
increment: int = 1
|
||||
begin: float = 0.0
|
||||
end: float = 1.0
|
||||
transition_time_in_ms : float = 500
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
|
||||
|
||||
@dataclass
|
||||
class EffectAlexaSwipeConfig:
|
||||
primary_color_width: float = 20 # in degrees
|
||||
transition_width: float = 30 # in degrees
|
||||
swipe_speed: float = 2 * 360 # in degrees per second
|
||||
bell_curve_width_in_leds: float = 3
|
||||
start_position: float = 180 # in degrees
|
||||
forward: bool = True
|
||||
primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
|
||||
secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0))
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack(
|
||||
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
|
||||
self.bell_curve_width_in_leds, self.start_position,
|
||||
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectRandomTwoColorInterpolationConfig:
|
||||
cycle_durations_ms: int = 6000
|
||||
start_with_existing: bool = True
|
||||
num_segments: int = 3
|
||||
hue1_random: bool = False
|
||||
hue2_random: bool = False
|
||||
color1: ColorHSV = field(default_factory=lambda: ColorHSV(240, 1, 1))
|
||||
color2: ColorHSV = field(default_factory=lambda: ColorHSV(192, 1, 1))
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
|
||||
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
|
||||
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
|
||||
self.num_segments, self.hue1_random,
|
||||
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
|
||||
|
||||
def __repr__(self):
|
||||
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCircularConfig:
|
||||
speed: float = 360 # in degrees per second
|
||||
width: float = 180 # in degrees
|
||||
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectSwipeAndChange:
|
||||
swipe: EffectAlexaSwipeConfig = field(default_factory=lambda: EffectAlexaSwipeConfig())
|
||||
change: EffectRandomTwoColorInterpolationConfig = field(default_factory=lambda: EffectRandomTwoColorInterpolationConfig())
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.swipe.as_bytes() + self.change.as_bytes()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectReverseSwipe:
|
||||
swipeSpeed: float = 2 * 360
|
||||
bellCurveWidthInLeds: float = 3
|
||||
startPosition: float = 180
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import serial_asyncio
|
||||
from led_cmds import (ColorRGBW, EffectCircularConfig, EffectStaticConfig,
|
||||
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
|
||||
EffectSwipeAndChange, EffectReverseSwipe)
|
||||
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
|
||||
from player import AudioPlayer
|
||||
from glob import glob
|
||||
from copy import deepcopy
|
||||
import os
|
||||
from hass_client import HomeAssistantClient
|
||||
from ruamel.yaml import YAML
|
||||
import warnings
|
||||
from typing import Optional, NamedTuple
|
||||
from mqtt_json import start_mqtt
|
||||
|
||||
yaml = YAML(typ='safe')
|
||||
|
||||
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
|
||||
|
||||
|
||||
class FigureColors(NamedTuple):
|
||||
primary: ColorRGBW
|
||||
secondary: ColorRGBW
|
||||
bg: ColorRGBW
|
||||
accent: ColorRGBW
|
||||
|
||||
|
||||
def parse_color(color_str: str):
|
||||
if isinstance(color_str, ColorRGBW):
|
||||
return color_str
|
||||
elif color_str.startswith("#"):
|
||||
color_str = color_str.lstrip('#')
|
||||
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
return ColorRGBW(*t, 0)
|
||||
elif color_str.startswith("w"):
|
||||
color_str = color_str.lstrip("w")
|
||||
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
|
||||
else:
|
||||
raise ValueError(f"Unrecognized color format: {color_str!r}")
|
||||
|
||||
|
||||
def load_config(config_path):
|
||||
# Schema documented in config.yml.example.
|
||||
with open(os.path.join(config_path, "config.yml")) as cfg_file:
|
||||
cfg = yaml.load(cfg_file)
|
||||
for figure_name, figure_cfg in cfg["figures"].items():
|
||||
figure_cfg["colors"] = FigureColors(*(parse_color(c) for c in figure_cfg["colors"]))
|
||||
if 'media_files' not in figure_cfg:
|
||||
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
|
||||
return cfg
|
||||
|
||||
|
||||
def hass_service(hass, domain, service, **kwargs):
|
||||
asyncio.create_task(hass.call_service(domain, service, kwargs))
|
||||
|
||||
|
||||
class MusicMouseState:
|
||||
def __init__(self, protocol: MusicMouseProtocol):
|
||||
self.active_figure: Optional[
|
||||
str] = None # None if no figure is placed on the reader, or the name of the figure
|
||||
self.last_partially_played_figure: Optional[
|
||||
str] = None # figure whose playlist wasn't played completely and was removed
|
||||
|
||||
self.current_mouse_led_effect = None
|
||||
self.current_led_ring_effect = None
|
||||
self.protocol: MusicMouseProtocol = protocol
|
||||
self.button_led_brightness = None
|
||||
|
||||
def mouse_led_effect(self, effect_cfg):
|
||||
self.current_mouse_led_effect = effect_cfg
|
||||
self.protocol.mouse_led_effect(effect_cfg)
|
||||
|
||||
def led_ring_effect(self, effect_cfg):
|
||||
self.current_led_ring_effect = effect_cfg
|
||||
self.protocol.led_ring_effect(effect_cfg)
|
||||
self.protocol.shelve_led_effect(effect_cfg)
|
||||
|
||||
def button_leds(self, brightness):
|
||||
assert 0 <= brightness <= 1
|
||||
self.protocol.button_background_led_prev(brightness)
|
||||
self.protocol.button_background_led_next(brightness)
|
||||
self.button_led_brightness = brightness
|
||||
|
||||
def reset(self):
|
||||
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
|
||||
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, protocol, hass, cfg):
|
||||
self.cfg = cfg
|
||||
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
|
||||
self.audio_player.set_volume(50)
|
||||
self.mmstate = MusicMouseState(protocol)
|
||||
self.protocol = protocol
|
||||
self.hass = hass
|
||||
|
||||
vol_min = self.cfg["general"].get("min_volume", None)
|
||||
vol_max = self.cfg["general"].get("max_volume", None)
|
||||
self.audio_player.set_volume_limits(vol_min, vol_max)
|
||||
protocol.register_message_callback(self.on_firmware_msg)
|
||||
|
||||
self.audio_player.on_playlist_end_callback = self._on_playlist_end
|
||||
self.playlists = {
|
||||
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
|
||||
for fig, fig_cfg in cfg['figures'].items()
|
||||
}
|
||||
self._rfid_to_figure_name = {
|
||||
bytes.fromhex(figure_cfg["id"]): figure_name
|
||||
for figure_name, figure_cfg in cfg["figures"].items()
|
||||
}
|
||||
|
||||
self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0)))
|
||||
shelf_eff = EffectCircularConfig()
|
||||
shelf_eff.color = ColorRGBW(0, 0, 0.4, 0)
|
||||
shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self.protocol.shelve_led_effect(shelf_eff)
|
||||
|
||||
def _on_playlist_end(self):
|
||||
if not self.audio_player.is_playing():
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
self._run_off_animation()
|
||||
else:
|
||||
print("Playlist end was called, even if player remains playing?!")
|
||||
|
||||
def handle_rfid_event(self, tagid):
|
||||
if tagid == bytes.fromhex("0000000000"):
|
||||
if self.audio_player.is_playing():
|
||||
print("Got 000 rfid -> playing off animation")
|
||||
self._run_off_animation()
|
||||
self.audio_player.pause()
|
||||
self.mmstate.last_partially_played_figure = self.mmstate.active_figure
|
||||
else:
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
|
||||
self.mmstate.active_figure = None
|
||||
elif tagid in self._rfid_to_figure_name:
|
||||
newly_placed_figure = self._rfid_to_figure_name[tagid]
|
||||
colors = self.cfg["figures"][newly_placed_figure]["colors"]
|
||||
self._start_animation(colors.primary, colors.secondary)
|
||||
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
|
||||
|
||||
if newly_placed_figure in self.cfg['figures']:
|
||||
if self.mmstate.last_partially_played_figure == newly_placed_figure:
|
||||
print("Continuing playlist")
|
||||
self.audio_player.play()
|
||||
else:
|
||||
print("Restarting playlist")
|
||||
self.audio_player.set_playlist(
|
||||
self.audio_player.create_playlist(self.cfg['figures'][newly_placed_figure]['media_files']))
|
||||
self.audio_player.play_from_start()
|
||||
|
||||
self.mmstate.active_figure = newly_placed_figure
|
||||
else:
|
||||
warnings.warn(f"Unknown figure/tag with id {tagid}")
|
||||
|
||||
def on_firmware_msg(self, _, message):
|
||||
print("FW msg:", message)
|
||||
if isinstance(message, RfidTokenRead):
|
||||
self.handle_rfid_event(message.id)
|
||||
elif isinstance(message, RotaryEncoderEvent):
|
||||
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(message.increment)
|
||||
if message.direction == 2:
|
||||
self.audio_player.change_volume(volume_increment)
|
||||
elif message.direction == 1:
|
||||
self.audio_player.change_volume(-volume_increment)
|
||||
elif isinstance(message, ButtonEvent):
|
||||
btn = message.button
|
||||
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
|
||||
self.audio_player.previous()
|
||||
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
|
||||
self.audio_player.next()
|
||||
elif message.button == "rotary" and message.event == "pressed":
|
||||
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
|
||||
elif isinstance(message, TouchButtonPress):
|
||||
figure = self.mmstate.active_figure
|
||||
if figure and self.audio_player.is_playing():
|
||||
figure_colors = self.cfg["figures"][figure]["colors"]
|
||||
self.protocol.mouse_led_effect(
|
||||
EffectStaticConfig(figure_colors.accent, *mouse_leds_index_ranges[message.touch_button]))
|
||||
|
||||
colors = {
|
||||
TouchButton.RIGHT_FOOT: {
|
||||
'rgb_color': [235, 255, 67]
|
||||
},
|
||||
TouchButton.LEFT_FOOT: {
|
||||
'color_temp': 469
|
||||
},
|
||||
TouchButton.RIGHT_EAR: {
|
||||
'rgb_color': [101, 49, 255]
|
||||
},
|
||||
TouchButton.LEFT_EAR: {
|
||||
'rgb_color': [255, 74, 254]
|
||||
},
|
||||
}
|
||||
hass_service(
|
||||
self.hass,
|
||||
"light",
|
||||
"turn_on",
|
||||
entity_id=["light.kinderzimmer_fluter", "light.music_mouse_regal_licht"],
|
||||
**colors[message.touch_button])
|
||||
|
||||
elif isinstance(message, TouchButtonRelease):
|
||||
figure = self.mmstate.active_figure
|
||||
eff_change = EffectRandomTwoColorInterpolationConfig()
|
||||
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
|
||||
*mouse_leds_index_ranges[message.touch_button])
|
||||
if figure and self.audio_player.is_playing():
|
||||
colors = self.cfg["figures"][figure]["colors"]
|
||||
eff_static.color = colors.primary
|
||||
self.protocol.mouse_led_effect(eff_static)
|
||||
|
||||
if figure and self.audio_player.is_playing():
|
||||
colors = self.cfg["figures"][figure]["colors"]
|
||||
eff_change.color1 = colors.primary
|
||||
eff_change.color2 = colors.secondary
|
||||
eff_change.start_with_existing = True
|
||||
self.protocol.mouse_led_effect(eff_change)
|
||||
|
||||
def _start_animation(self, primary_color, secondary_color):
|
||||
ring_eff = EffectSwipeAndChange()
|
||||
ring_eff.swipe.primary_color = primary_color
|
||||
ring_eff.swipe.secondary_color = secondary_color
|
||||
ring_eff.swipe.swipe_speed = 180
|
||||
ring_eff.change.color1 = primary_color
|
||||
ring_eff.change.color2 = secondary_color
|
||||
self.mmstate.led_ring_effect(ring_eff)
|
||||
|
||||
mouse_eff = deepcopy(ring_eff)
|
||||
mouse_eff.swipe.start_position = 6 / 45 * 360
|
||||
mouse_eff.swipe.bell_curve_width_in_leds = 16
|
||||
mouse_eff.swipe.swipe_speed = 180
|
||||
self.mmstate.mouse_led_effect(mouse_eff)
|
||||
|
||||
def _run_off_animation(self):
|
||||
print("Running off animation")
|
||||
ring_eff = EffectReverseSwipe()
|
||||
self.mmstate.led_ring_effect(ring_eff)
|
||||
|
||||
mouse_eff = EffectReverseSwipe()
|
||||
mouse_eff.startPosition = 6 / 45 * 360
|
||||
self.mmstate.mouse_led_effect(mouse_eff)
|
||||
|
||||
self.mmstate.button_leds(0)
|
||||
|
||||
|
||||
def main(config_path):
|
||||
cfg = load_config(config_path)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop)
|
||||
|
||||
coro = serial_asyncio.create_serial_connection(loop,
|
||||
MusicMouseProtocol,
|
||||
cfg["general"]["serial_port"],
|
||||
baudrate=115200)
|
||||
transport, protocol = loop.run_until_complete(coro)
|
||||
controller = Controller(protocol, hass, cfg)
|
||||
mqtt_cfg = cfg["general"]["mqtt"]
|
||||
loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"], mqtt_cfg["password"] ))
|
||||
loop.create_task(hass.connect())
|
||||
return controller, loop
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 2:
|
||||
controller, loop = main(config_path=sys.argv[1])
|
||||
loop.run_forever()
|
||||
loop.close()
|
||||
else:
|
||||
print("Error: run with config file path as first argument")
|
||||
@@ -1,180 +0,0 @@
|
||||
from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
|
||||
import asyncio
|
||||
import aiomqtt
|
||||
import json
|
||||
|
||||
|
||||
class ShelveLightMqtt:
|
||||
def __init__(self, protocol, client: aiomqtt.Client):
|
||||
self._protocol = protocol
|
||||
self._mqtt_client = client
|
||||
|
||||
self._state = {
|
||||
"state": "OFF",
|
||||
"color": {
|
||||
"r": 255,
|
||||
"g": 255,
|
||||
"b": 255,
|
||||
"w": 0,
|
||||
},
|
||||
"color_mode": "rgbw",
|
||||
"brightness": 30,
|
||||
"effect": "static",
|
||||
}
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
|
||||
self._discovery_spec = self._create_discovery_msg_light()
|
||||
|
||||
async def init(self):
|
||||
"""Init method, because constructor can't be async"""
|
||||
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
await self._send_autodiscovery_msg()
|
||||
await self._notify_mqtt_state({"state": "OFF"})
|
||||
|
||||
async def handle_light_message(self, msg):
|
||||
if msg.topic.value == self._discovery_spec['command_topic']:
|
||||
payload = msg.payload.decode()
|
||||
new_state = json.loads(payload)
|
||||
print("IN ", new_state)
|
||||
await self._update_state(new_state)
|
||||
await self._notify_mqtt_state(new_state)
|
||||
|
||||
async def _update_state(self, new_state):
|
||||
"""Merges current state with new state, updates device"""
|
||||
|
||||
# memorize last color - this is used for effects that need 2 colors
|
||||
if 'color' in new_state:
|
||||
brightness = new_state.get('brightness', self._state['brightness'])
|
||||
new_color = self._color_from_json(new_state['color'], brightness)
|
||||
current_color = self._color_from_json(self._state['color'])
|
||||
if new_color != current_color:
|
||||
self._last_color = current_color
|
||||
print("last color", self._last_color)
|
||||
|
||||
self._state.update(new_state)
|
||||
self._update_device()
|
||||
|
||||
@staticmethod
|
||||
def _color_from_json(json_color, brightness=255):
|
||||
args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w'))
|
||||
return ColorRGBW(*args)
|
||||
|
||||
def _update_device(self):
|
||||
s = self._state
|
||||
current_color = self._color_from_json(s['color'], brightness=s["brightness"])
|
||||
transition = s.get("transition", 0.3) * 1000
|
||||
print(f"Effect {s['effect']} Transition {transition}")
|
||||
|
||||
if s['state'] == "OFF":
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_time_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
elif s['effect'] == 'static':
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(current_color)
|
||||
elif s['effect'] == 'circular':
|
||||
eff = EffectCircularConfig(speed=180, width=90, color=current_color)
|
||||
elif s['effect'] == 'wipeup':
|
||||
eff = EffectSwipeAndChange()
|
||||
eff.swipe.secondary_color = current_color
|
||||
eff.swipe.primary_color = self._last_color
|
||||
eff.swipe.bell_curve_width_in_leds = 10
|
||||
eff.swipe.transition_width = 30
|
||||
eff.swipe.start_position = 0
|
||||
eff.swipe.swipe_speed = 260
|
||||
eff.change.color1 = current_color
|
||||
eff.change.color2 = self._last_color
|
||||
elif s['effect'] == "twocolor":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "twocolorrandom":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.hue1_random = True
|
||||
eff.hue2_random = True
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "side_0.2":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.2_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.2_inc8":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=8, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.2":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.2_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.5":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.5_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=4, transition_time_in_ms=transition)
|
||||
else:
|
||||
print(f"Unknown effect {s['effect']}")
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self._protocol.shelve_led_effect(eff)
|
||||
|
||||
@staticmethod
|
||||
def _create_discovery_msg_light(base_name="musicmouse_json",
|
||||
display_name="Music Mouse Regal Licht"):
|
||||
id = "shelve"
|
||||
return {
|
||||
'platform': 'mqtt',
|
||||
'schema': 'json',
|
||||
'name': display_name,
|
||||
'unique_id': f'{base_name}_{id}',
|
||||
'command_topic': f'{base_name}/lights_{id}/command',
|
||||
'state_topic': f'{base_name}/lights_{id}/state',
|
||||
'color_mode': True,
|
||||
'brightness': True,
|
||||
#'device': {
|
||||
# 'manufacturer': 'bauer.tech',
|
||||
# 'model': "SK6812 LED strip",
|
||||
#},
|
||||
'effect': True,
|
||||
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom',
|
||||
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
|
||||
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4"],
|
||||
'supported_color_modes': ['rgbw'],
|
||||
}
|
||||
|
||||
async def _send_autodiscovery_msg(self):
|
||||
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
|
||||
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True)
|
||||
|
||||
async def _notify_mqtt_state(self, state):
|
||||
state_payload = json.dumps(self._state)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
|
||||
|
||||
async def start_mqtt(music_mouse_protocol, server, username, password):
|
||||
reconnect_interval = 10 # [seconds]
|
||||
while True:
|
||||
try:
|
||||
async with aiomqtt.Client(hostname=server, username=username, password=password) as client:
|
||||
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
|
||||
await shelve_light.init()
|
||||
await client.subscribe("musicmouse_json/#")
|
||||
async for message in client.messages:
|
||||
await shelve_light.handle_light_message(message)
|
||||
except aiomqtt.MqttError as error:
|
||||
print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds')
|
||||
finally:
|
||||
await asyncio.sleep(reconnect_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
class DummyProtocol:
|
||||
def shelve_led_effect(self, effect):
|
||||
print("EFF ", repr(effect))
|
||||
|
||||
password = ""
|
||||
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))
|
||||
24
python-backend/musicmouse.service
Normal file
24
python-backend/musicmouse.service
Normal file
@@ -0,0 +1,24 @@
|
||||
# Put this into /etc/systemd/system/musicmouse.service
|
||||
#
|
||||
# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv:
|
||||
# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend
|
||||
#
|
||||
# A dropped serial link is now handled in-process (SerialLink reconnects), so
|
||||
# Restart=always is only for genuine crashes.
|
||||
|
||||
[Unit]
|
||||
Description=Music Mouse RFID Music Player
|
||||
After=multi-user.target sound.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WorkingDirectory=/opt/musicmouse/python-backend
|
||||
ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
5
python-backend/musicmouse/__init__.py
Normal file
5
python-backend/musicmouse/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""MusicMouse backend: an RFID music player for kids."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "2.0.0"
|
||||
433
python-backend/musicmouse/__main__.py
Normal file
433
python-backend/musicmouse/__main__.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""Entry point and composition root.
|
||||
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||
|
||||
This is the only module that knows which concrete implementations are in play; the
|
||||
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
|
||||
transport and which player get built here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import httpx2
|
||||
|
||||
from musicmouse import __version__
|
||||
from musicmouse.app import App
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import RealClock
|
||||
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.null_transport import NullTransport
|
||||
from musicmouse.devices.player import Player, VlcPlayer
|
||||
from musicmouse.devices.serial_link import SerialLink
|
||||
from musicmouse.library import MusicLibrary, default_worker_count
|
||||
from musicmouse.library.analysis import build_analyzer
|
||||
from musicmouse.reactions import register_all
|
||||
from musicmouse.services.base import Service
|
||||
from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.mqtt import MqttService, build_entities
|
||||
from musicmouse.services.podcasts import PodcastFeedService
|
||||
from musicmouse.services.screen import ScreenService
|
||||
from musicmouse.services.timers import TimerService
|
||||
from musicmouse.services.web import WebService
|
||||
from musicmouse.sound import ui_level
|
||||
from musicmouse.tippen.curriculum import CurriculumError
|
||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||
|
||||
_log = logging.getLogger("musicmouse")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
|
||||
)
|
||||
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--simulate",
|
||||
action="store_true",
|
||||
help="run against fake hardware and a fake player (no serial port, no audio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-hardware",
|
||||
action="store_true",
|
||||
help="real audio and a real web front-end, but no serial port: for a host with "
|
||||
"no mouse attached",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--script",
|
||||
type=Path,
|
||||
help="with --simulate: run a scenario file instead of the interactive prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
help="default: INFO",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.script and not args.simulate:
|
||||
parser.error("--script only makes sense together with --simulate")
|
||||
if args.simulate and args.no_hardware:
|
||||
parser.error("--simulate already runs without hardware; drop --no-hardware")
|
||||
return args
|
||||
|
||||
|
||||
def setup_logging(level: str) -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level),
|
||||
format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.log_level)
|
||||
|
||||
try:
|
||||
config = load_config(args.config, check_paths=True)
|
||||
except ConfigError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
tippen: TippenRuntime | None = None
|
||||
if config.general.tippen is not None:
|
||||
try:
|
||||
tippen = build_tippen_runtime(config.general.tippen)
|
||||
except CurriculumError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
runner = (
|
||||
run_simulated(config, args.config, args.script, tippen=tippen)
|
||||
if args.simulate
|
||||
else run_real(
|
||||
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
|
||||
)
|
||||
)
|
||||
try:
|
||||
asyncio.run(runner)
|
||||
except KeyboardInterrupt:
|
||||
_log.info("Interrupted")
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- real
|
||||
|
||||
|
||||
async def run_real(
|
||||
config: Config,
|
||||
config_path: Path,
|
||||
*,
|
||||
hardware: bool = True,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> None:
|
||||
bus = EventBus()
|
||||
await bus.start()
|
||||
clock = RealClock()
|
||||
general = config.general
|
||||
|
||||
link: SerialLink | None = None
|
||||
if hardware:
|
||||
link = SerialLink(
|
||||
general.serial_port,
|
||||
general.baudrate,
|
||||
reconnect_interval=general.reconnect_interval,
|
||||
clock=clock,
|
||||
)
|
||||
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
|
||||
link.attach(mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected)
|
||||
else:
|
||||
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
|
||||
|
||||
player = _build_player(bus, general, clock=clock)
|
||||
|
||||
library = await build_library(config)
|
||||
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
|
||||
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
|
||||
|
||||
_log.info(
|
||||
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
|
||||
__version__,
|
||||
len(config.figures),
|
||||
len(library.albums),
|
||||
# Report what the config says, with a marker when --no-hardware overrode it,
|
||||
# so the line never disagrees with the file it was started from.
|
||||
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
|
||||
general.alsa_device,
|
||||
general.mqtt.server if general.mqtt else "disabled",
|
||||
)
|
||||
try:
|
||||
await _run_forever(
|
||||
[
|
||||
*([link.run()] if link else []),
|
||||
player.run(),
|
||||
# `is_busy` reads `player.is_playing` directly rather than the library
|
||||
# knowing about playback at all: analysis must never compete with audio
|
||||
# decoding for CPU, and this device is otherwise idle most of the day, so
|
||||
# the pass simply resumes once it is. `on_batch` is unset unless the web
|
||||
# front-end is on - nothing else has a use for the notification.
|
||||
library.run_analysis(
|
||||
is_busy=lambda: player.is_playing,
|
||||
on_batch=_analysis_batch_hook(services),
|
||||
),
|
||||
*(service.run() for service in services),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
player.close()
|
||||
await bus.stop()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- simulated
|
||||
|
||||
|
||||
async def run_simulated(
|
||||
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
|
||||
) -> None:
|
||||
# Imported here so the production path never touches the simulator.
|
||||
from musicmouse.simulator.harness import build_simulation
|
||||
from musicmouse.simulator.repl import run_repl
|
||||
from musicmouse.simulator.script import run_script_file
|
||||
|
||||
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
|
||||
# real clock, so playback ticks along while you watch it.
|
||||
sim = await build_simulation(
|
||||
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
|
||||
)
|
||||
|
||||
services = _build_services(
|
||||
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
|
||||
)
|
||||
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
sim.app.library.run_analysis(
|
||||
is_busy=lambda: sim.player.is_playing,
|
||||
on_batch=_analysis_batch_hook(services),
|
||||
),
|
||||
name="library-analysis",
|
||||
)
|
||||
)
|
||||
try:
|
||||
if script is not None:
|
||||
await run_script_file(sim, script)
|
||||
else:
|
||||
await run_repl(sim)
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- wiring
|
||||
|
||||
|
||||
def wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
|
||||
"""Whether to open a serial port at all.
|
||||
|
||||
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
|
||||
for rather than inferred from a missing key, but it is still worth saying out loud
|
||||
every boot - a mouse whose RFID reader does nothing should say why.
|
||||
"""
|
||||
if no_hardware_flag:
|
||||
return False
|
||||
if config.general.serial_simulated:
|
||||
_log.warning(
|
||||
'general.serial_port is "%s": running without the mouse. '
|
||||
"The web front-end still works; RFID, buttons and LEDs do not.",
|
||||
SIMULATE,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
|
||||
"""A real player, or a silent one when the config asked for that.
|
||||
|
||||
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
|
||||
identically, it just makes no sound. Useful for working on the web UI without
|
||||
commandeering the machine's audio.
|
||||
"""
|
||||
if not general.audio_simulated:
|
||||
return VlcPlayer(
|
||||
bus,
|
||||
alsa_device=general.alsa_device,
|
||||
clock=clock,
|
||||
**VlcPlayer.volume_kwargs(general),
|
||||
)
|
||||
|
||||
_log.warning(
|
||||
'general.alsa_device is "%s": using a simulated player, so nothing will be '
|
||||
'audible. Set it to "default" for the system default output.',
|
||||
SIMULATE,
|
||||
)
|
||||
# Imported here rather than at module scope so the real audio path never loads the
|
||||
# simulator - and so this still runs on a machine without libVLC at all.
|
||||
from musicmouse.simulator.fake_player import FakePlayer
|
||||
|
||||
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
|
||||
|
||||
|
||||
async def build_library(config: Config) -> MusicLibrary:
|
||||
library_config = config.general.library
|
||||
workers = library_config.analysis_workers
|
||||
return await MusicLibrary.build(
|
||||
library_config.root,
|
||||
library_config.cache,
|
||||
frozenset(config.general.audio_extensions),
|
||||
analyzer=build_analyzer(),
|
||||
# Unset in the config means "use the machine": a first pass over an unanalyzed
|
||||
# library is hours of DSP, and there is no reason for a desktop to do it one
|
||||
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
|
||||
analysis_workers=default_worker_count() if workers is None else workers,
|
||||
figure_kinds=config.figure_kinds,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(
|
||||
config: Config,
|
||||
bus: EventBus,
|
||||
mouse: MusicMouseDevice,
|
||||
player: Player,
|
||||
library: MusicLibrary,
|
||||
*,
|
||||
clock: RealClock,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> App:
|
||||
app = App(
|
||||
config=config,
|
||||
bus=bus,
|
||||
mouse=mouse,
|
||||
player=player,
|
||||
library=library,
|
||||
playlists=library.figure_playlists(),
|
||||
clock=clock,
|
||||
tippen=tippen,
|
||||
)
|
||||
register_all(bus, app)
|
||||
return app
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Service)
|
||||
|
||||
|
||||
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
|
||||
return next((s for s in services if isinstance(s, kind)), None)
|
||||
|
||||
|
||||
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
||||
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
||||
as background analysis lands, instead of only after a manual reload. `None` when
|
||||
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
||||
tell".
|
||||
"""
|
||||
web_service = _service_of_type(services, WebService)
|
||||
return web_service.hub.broadcast_library if web_service else None
|
||||
|
||||
|
||||
def _build_services(
|
||||
app: App,
|
||||
mouse: MusicMouseDevice,
|
||||
player: Player,
|
||||
*,
|
||||
clock: RealClock,
|
||||
config_path: Path,
|
||||
) -> list[Service]:
|
||||
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
|
||||
services: list[Service] = []
|
||||
|
||||
mqtt_config = app.config.general.mqtt
|
||||
if mqtt_config is None:
|
||||
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
||||
else:
|
||||
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
||||
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
|
||||
|
||||
web_config = app.config.general.web
|
||||
if web_config is None:
|
||||
_log.info("No web section in the config: the web front-end is off")
|
||||
else:
|
||||
services.append(WebService(app, web_config, config_path))
|
||||
|
||||
lirc_config = app.config.general.lirc
|
||||
if lirc_config is None:
|
||||
_log.info("No lirc section in the config: the IR remote is off")
|
||||
else:
|
||||
services.append(LircService(app, lirc_config, clock=clock))
|
||||
|
||||
screen_config = app.config.general.screen
|
||||
if screen_config is not None:
|
||||
general = app.config.general
|
||||
services.append(
|
||||
ScreenService(
|
||||
app.bus,
|
||||
screen_config,
|
||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||
is_playing=lambda: app.player.is_playing,
|
||||
clock=clock,
|
||||
)
|
||||
)
|
||||
|
||||
# Unconditional, like the overlay it serves: the bus timer and the school-day badge
|
||||
# need nothing configured, and the parents' timer is only started from parent mode.
|
||||
general = app.config.general
|
||||
services.append(
|
||||
TimerService(
|
||||
app.bus,
|
||||
app.state,
|
||||
general.clock,
|
||||
cache_file=general.library.cache / "holidays.json",
|
||||
client=httpx2.AsyncClient(timeout=30.0),
|
||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||
clock=clock,
|
||||
)
|
||||
)
|
||||
|
||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||
# into its folder, so there is nothing to gate here with its own config section.
|
||||
web_service = _service_of_type(services, WebService)
|
||||
services.append(
|
||||
PodcastFeedService(
|
||||
app.library,
|
||||
client=httpx2.AsyncClient(timeout=30.0),
|
||||
on_change=lambda: app.rescan_library(
|
||||
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||
),
|
||||
episode_limit=app.config.general.podcast_episode_limit,
|
||||
)
|
||||
)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
|
||||
tasks = [asyncio.create_task(coro) for coro in coroutines]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
134
python-backend/musicmouse/app.py
Normal file
134
python-backend/musicmouse/app.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import Config, FigureColors
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["App", "AppState", "FigureSelector", "ParentTimer"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParentTimer:
|
||||
"""The countdown parents start from parent mode. Wall-clock Unix seconds, so every
|
||||
browser can draw it against its own clock."""
|
||||
|
||||
started_at: float
|
||||
ends_at: float
|
||||
#: Ran out and is chiming; cleared after a minute or by the next thing anyone does.
|
||||
ringing: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FigureSelector:
|
||||
"""The LED ring offering a figure's albums to pick from. Owned by
|
||||
:mod:`musicmouse.reactions.selection`."""
|
||||
|
||||
figure: str
|
||||
choices: tuple[Playlist, ...]
|
||||
#: The highlighted choice - not necessarily the one playing until it is confirmed.
|
||||
selected: int
|
||||
#: Clock time at which the ring gives up waiting; pushed back by every turn.
|
||||
deadline: float
|
||||
#: Clock time of the last tick sound, so turning fast does not stack them up.
|
||||
last_tick: float = -math.inf
|
||||
expiry: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppState:
|
||||
"""State that belongs to no single device but is shared between reactions."""
|
||||
|
||||
#: Figure that was taken off the reader mid-playlist, so putting it back resumes
|
||||
#: instead of starting over. Cleared once its playlist runs out.
|
||||
last_partially_played_figure: str | None = None
|
||||
|
||||
#: Figure -> album id it last played, for figures with more than one album: putting
|
||||
#: it back picks that one again. Kept when another figure is placed in between -
|
||||
#: only the position within the album is forgotten then.
|
||||
figure_albums: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
#: The ring's album picker, while it is open.
|
||||
selector: FigureSelector | None = None
|
||||
|
||||
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
||||
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||
mqtt_connected: bool = False
|
||||
|
||||
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||
lirc_connected: bool = False
|
||||
|
||||
#: Owned by :class:`~musicmouse.services.timers.TimerService`.
|
||||
parent_timer: ParentTimer | None = None
|
||||
#: Whether today is a school day; ``None`` on a weekday the holiday calendar could
|
||||
#: not be fetched for (no internet, no cache yet).
|
||||
school_day: bool | None = None
|
||||
#: Why not, when it is not: ``"Wochenende"``, ``"Herbstferien"``, ...
|
||||
school_day_reason: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class App:
|
||||
config: Config
|
||||
bus: EventBus
|
||||
mouse: MusicMouseDevice
|
||||
player: Player
|
||||
library: MusicLibrary
|
||||
#: Figure -> the albums it can play, in the order the ring offers them.
|
||||
playlists: dict[str, tuple[Playlist, ...]]
|
||||
clock: Clock = field(default_factory=RealClock)
|
||||
state: AppState = field(default_factory=AppState)
|
||||
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||
tippen: TippenRuntime | None = None
|
||||
|
||||
def colors(self, figure: str) -> FigureColors:
|
||||
return self.config.figures[figure].colors
|
||||
|
||||
def choices(self, figure: str) -> tuple[Playlist, ...]:
|
||||
"""The albums ``figure`` can play; empty, with a warning, if it has none."""
|
||||
choices = self.playlists.get(figure, ())
|
||||
if not choices:
|
||||
_log.warning("No playlist for figure %r", figure)
|
||||
return choices
|
||||
|
||||
def default_choice(self, figure: str) -> int:
|
||||
"""Which of the figure's albums plays when it is put down: the one it played
|
||||
last, or else the first."""
|
||||
remembered = self.state.figure_albums.get(figure)
|
||||
for index, playlist in enumerate(self.playlists.get(figure, ())):
|
||||
if playlist.album_id == remembered:
|
||||
return index
|
||||
return 0
|
||||
|
||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||
"""The library album a playlist came from, if any."""
|
||||
return self.library.get(playlist.album_id if playlist else None)
|
||||
|
||||
async def rescan_library(
|
||||
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||
) -> None:
|
||||
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
|
||||
|
||||
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
|
||||
can change what's on disk on its own, such as the podcast feed poller.
|
||||
"""
|
||||
await self.library.refresh()
|
||||
self.playlists.clear()
|
||||
self.playlists.update(self.library.figure_playlists())
|
||||
if broadcast is not None:
|
||||
await broadcast()
|
||||
159
python-backend/musicmouse/blubb.py
Normal file
159
python-backend/musicmouse/blubb.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""A synthesised "blubb": two bubbles, for the screen switching on or off.
|
||||
|
||||
Built from samples rather than shipped as a file - the same idea as the front-end's
|
||||
WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the
|
||||
player's volume instead of being fixed in a recording.
|
||||
|
||||
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
|
||||
it reaches the surface). Two of them make the "blubb"; switching on the second one is
|
||||
higher, switching off it is lower, so the two directions are told apart by ear.
|
||||
|
||||
The parents' timer has its own sound here too: a soft "ding-dong" bell
|
||||
(:func:`chime_wav`), made the same way. So do the LED ring's album picker: a short
|
||||
tick for every step (:func:`tick_wav`) and a rising two-note "pling" when an album is
|
||||
picked (:func:`confirm_wav`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import io
|
||||
import math
|
||||
import wave
|
||||
from array import array
|
||||
from collections.abc import Sequence
|
||||
|
||||
__all__ = ["RATE", "blubb_wav", "chime_wav", "confirm_wav", "tick_wav"]
|
||||
|
||||
RATE = 44100
|
||||
|
||||
#: (start Hz, end Hz, start s, length s) of each bubble.
|
||||
_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13))
|
||||
_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15))
|
||||
|
||||
|
||||
def blubb_wav(*, on: bool, gain: float) -> bytes:
|
||||
"""A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale."""
|
||||
bubbles = _ON if on else _OFF
|
||||
length = max(start + duration for _, _, start, duration in bubbles)
|
||||
samples = [0.0] * (int(length * RATE) + 1)
|
||||
for start_hz, end_hz, start, duration in bubbles:
|
||||
_add_bubble(samples, start_hz, end_hz, int(start * RATE), duration)
|
||||
|
||||
peak = max(abs(s) for s in samples) or 1.0
|
||||
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
||||
return _wav(samples, scale)
|
||||
|
||||
|
||||
#: (Hz, start s) of each bell strike: a falling major third, "ding-dong".
|
||||
_CHIME = ((1318.5, 0.0), (1046.5, 0.45))
|
||||
|
||||
#: How long each strike rings out.
|
||||
_CHIME_DECAY = 1.6
|
||||
|
||||
|
||||
def chime_wav(*, gain: float) -> bytes:
|
||||
"""A soft two-note bell for the parents' timer running out. Same format as
|
||||
:func:`blubb_wav`."""
|
||||
length = max(start for _, start in _CHIME) + _CHIME_DECAY
|
||||
samples = [0.0] * (int(length * RATE) + 1)
|
||||
for frequency, start in _CHIME:
|
||||
_add_strike(samples, frequency, int(start * RATE))
|
||||
peak = max(abs(s) for s in samples) or 1.0
|
||||
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
||||
return _wav(samples, scale)
|
||||
|
||||
|
||||
#: (Hz, start s) of the tick's partials: a high, woody click rather than a beep.
|
||||
_TICK = ((2100.0, 0.0), (3350.0, 0.0))
|
||||
_TICK_LENGTH = 0.03
|
||||
_TICK_DECAY = 160.0
|
||||
|
||||
#: (Hz, start s) of the confirm sound: a rising fifth, quick, "pling-pling".
|
||||
_CONFIRM = ((880.0, 0.0), (1318.5, 0.09))
|
||||
_CONFIRM_LENGTH = 0.22
|
||||
_CONFIRM_DECAY = 18.0
|
||||
|
||||
|
||||
def tick_wav(*, gain: float) -> bytes:
|
||||
"""A click of a few tens of milliseconds, for every step of the album picker. Same
|
||||
format as :func:`blubb_wav`."""
|
||||
return _scaled(_pings(_TICK, _TICK_LENGTH, _TICK_DECAY), gain)
|
||||
|
||||
|
||||
def confirm_wav(*, gain: float) -> bytes:
|
||||
"""Two short rising notes for an album picked on the ring. Same format as
|
||||
:func:`blubb_wav`."""
|
||||
return _scaled(_pings(_CONFIRM, _CONFIRM_LENGTH, _CONFIRM_DECAY), gain)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _pings(
|
||||
notes: tuple[tuple[float, float], ...], length: float, decay: float
|
||||
) -> tuple[float, ...]:
|
||||
total = max(start for _, start in notes) + length
|
||||
samples = [0.0] * (int(total * RATE) + 1)
|
||||
attack = int(0.002 * RATE)
|
||||
count = int(length * RATE)
|
||||
for frequency, start in notes:
|
||||
offset = int(start * RATE)
|
||||
for i in range(count):
|
||||
t = i / RATE
|
||||
index = offset + i
|
||||
if index < len(samples):
|
||||
samples[index] += (
|
||||
math.sin(2 * math.pi * frequency * t)
|
||||
* math.exp(-decay * t)
|
||||
* min(1.0, i / attack)
|
||||
)
|
||||
# Cached: a tick plays on every step of the knob, from inside the event loop.
|
||||
return tuple(samples)
|
||||
|
||||
|
||||
def _scaled(samples: tuple[float, ...], gain: float) -> bytes:
|
||||
peak = max(abs(s) for s in samples) or 1.0
|
||||
return _wav(samples, 32767 * max(0.0, min(1.0, gain)) / peak)
|
||||
|
||||
|
||||
def _wav(samples: Sequence[float], scale: float) -> bytes:
|
||||
pcm = array("h", (round(s * scale) for s in samples))
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as out:
|
||||
out.setnchannels(1)
|
||||
out.setsampwidth(2)
|
||||
out.setframerate(RATE)
|
||||
out.writeframes(pcm.tobytes())
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _add_bubble(
|
||||
samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float
|
||||
) -> None:
|
||||
count = int(duration * RATE)
|
||||
attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks
|
||||
phase = 0.0
|
||||
for i in range(count):
|
||||
t = i / count
|
||||
# Exponential sweep, fast at first like the real thing.
|
||||
frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2)
|
||||
phase += 2 * math.pi * frequency / RATE
|
||||
envelope = min(1.0, i / attack) * math.exp(-5 * t)
|
||||
index = offset + i
|
||||
if index < len(samples):
|
||||
samples[index] += math.sin(phase) * envelope
|
||||
|
||||
|
||||
def _add_strike(samples: list[float], frequency: float, offset: int) -> None:
|
||||
"""A bell: the fundamental plus two quieter, faster-fading overtones."""
|
||||
count = int(_CHIME_DECAY * RATE)
|
||||
attack = int(0.006 * RATE)
|
||||
partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay
|
||||
for i in range(count):
|
||||
t = i / RATE
|
||||
value = sum(
|
||||
level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t)
|
||||
for ratio, level, decay in partials
|
||||
)
|
||||
index = offset + i
|
||||
if index < len(samples):
|
||||
samples[index] += value * min(1.0, i / attack)
|
||||
157
python-backend/musicmouse/bus.py
Normal file
157
python-backend/musicmouse/bus.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""The event bus.
|
||||
|
||||
Everything in the process is serialised through one FIFO queue on one loop, which is
|
||||
what makes "last event wins" a well-defined rule for LED zone arbitration and what
|
||||
makes scenario tests deterministic.
|
||||
|
||||
Handlers may be sync or async; async handlers are awaited, so one event is fully
|
||||
handled before the next is dispatched. A handler that raises is logged and does not
|
||||
stop the others or the bus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, TypeAlias, TypeVar
|
||||
|
||||
from musicmouse.events import Event
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
||||
|
||||
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
||||
#: parameterises it the way the PEP 695 form did.
|
||||
E = TypeVar("E", bound=Event)
|
||||
|
||||
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
|
||||
Unsubscribe: TypeAlias = Callable[[], None]
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[type[Event], list[Handler[Any]]] = {}
|
||||
self._wildcard: list[Handler[Any]] = []
|
||||
self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {}
|
||||
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._dispatcher: asyncio.Task[None] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._dispatcher is not None:
|
||||
return
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._dispatcher = asyncio.create_task(self._run(), name="event-bus")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._dispatcher is None:
|
||||
return
|
||||
self._dispatcher.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._dispatcher
|
||||
self._dispatcher = None
|
||||
self._loop = None
|
||||
|
||||
async def __aenter__(self) -> EventBus:
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.stop()
|
||||
|
||||
# --------------------------------------------------------------- subscription
|
||||
|
||||
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
||||
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
||||
self._handlers.setdefault(event_type, []).append(handler)
|
||||
self._resolved.clear()
|
||||
|
||||
def unsubscribe() -> None:
|
||||
handlers = self._handlers.get(event_type)
|
||||
if handlers and handler in handlers:
|
||||
handlers.remove(handler)
|
||||
self._resolved.clear()
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe:
|
||||
"""Register ``handler`` for every event. Useful for logging and broadcasting."""
|
||||
self._wildcard.append(handler)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
if handler in self._wildcard:
|
||||
self._wildcard.remove(handler)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
# ---------------------------------------------------------------- publication
|
||||
|
||||
def emit(self, event: Event) -> None:
|
||||
"""Queue ``event`` for dispatch. Safe to call from any thread.
|
||||
|
||||
libVLC fires its callbacks on its own thread; this is where that crossing is
|
||||
made safe instead of reaching the serial transport off-loop.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
raise RuntimeError("EventBus.emit() before start()")
|
||||
try:
|
||||
running = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running = None
|
||||
if running is loop:
|
||||
self._queue.put_nowait(event)
|
||||
else:
|
||||
loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
||||
|
||||
async def drain(self) -> None:
|
||||
"""Wait until every queued event, and everything they emitted, is handled."""
|
||||
await self._queue.join()
|
||||
|
||||
async def emit_and_wait(self, event: Event) -> None:
|
||||
self.emit(event)
|
||||
await self.drain()
|
||||
|
||||
# -------------------------------------------------------------------- internals
|
||||
|
||||
async def _run(self) -> None:
|
||||
while True:
|
||||
event = await self._queue.get()
|
||||
try:
|
||||
await self._dispatch(event)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
async def _dispatch(self, event: Event) -> None:
|
||||
for handler in self._handlers_for(type(event)):
|
||||
try:
|
||||
result = handler(event)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("Handler %s failed on %r", _name(handler), event)
|
||||
|
||||
def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]:
|
||||
cached = self._resolved.get(event_type)
|
||||
if cached is None:
|
||||
matched: list[Handler[Any]] = []
|
||||
for klass in event_type.__mro__:
|
||||
if klass is object:
|
||||
continue
|
||||
matched.extend(self._handlers.get(klass, ()))
|
||||
cached = tuple(matched)
|
||||
self._resolved[event_type] = cached
|
||||
# Wildcards are not cached: they are appended last and change rarely.
|
||||
return cached + tuple(self._wildcard)
|
||||
|
||||
|
||||
def _name(handler: Handler[Any]) -> str:
|
||||
return getattr(handler, "__qualname__", repr(handler))
|
||||
94
python-backend/musicmouse/clock.py
Normal file
94
python-backend/musicmouse/clock.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Time, behind a protocol.
|
||||
|
||||
Anything that waits takes a :class:`Clock` instead of calling :func:`asyncio.sleep`
|
||||
directly. Under :class:`RealClock` a scenario runs in real time; under
|
||||
:class:`FakeClock` the identical scenario runs in microseconds, which is what makes
|
||||
``wait 1s`` affordable inside the test suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import heapq
|
||||
import itertools
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Protocol
|
||||
|
||||
__all__ = ["Clock", "FakeClock", "RealClock"]
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
def now(self) -> float:
|
||||
"""Monotonic seconds. Only differences are meaningful."""
|
||||
...
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
"""Suspend the calling task for ``seconds``."""
|
||||
...
|
||||
|
||||
async def advance(self, seconds: float) -> None:
|
||||
"""Let ``seconds`` pass, from the driver's point of view."""
|
||||
...
|
||||
|
||||
|
||||
class RealClock:
|
||||
def now(self) -> float:
|
||||
return time.monotonic()
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
async def advance(self, seconds: float) -> None:
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Virtual time.
|
||||
|
||||
``sleep()`` parks the caller until ``advance()`` moves time past its deadline.
|
||||
``idle`` is awaited after each wake-up so that whatever the woken task emitted has
|
||||
been fully handled before virtual time moves on - pass ``EventBus.drain``.
|
||||
"""
|
||||
|
||||
def __init__(self, start: float = 0.0, idle: Callable[[], Awaitable[None]] | None = None):
|
||||
self._now = start
|
||||
self._idle = idle
|
||||
self._counter = itertools.count()
|
||||
self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = []
|
||||
|
||||
def now(self) -> float:
|
||||
return self._now
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
if seconds <= 0:
|
||||
await self._settle()
|
||||
return
|
||||
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
||||
heapq.heappush(self._sleepers, (self._now + seconds, next(self._counter), future))
|
||||
await future
|
||||
|
||||
async def advance(self, seconds: float) -> None:
|
||||
# Settle first: a task created but not yet started has not registered its
|
||||
# sleep, and would otherwise have its deadline computed from the new time.
|
||||
await self._settle()
|
||||
target = self._now + max(0.0, seconds)
|
||||
while self._sleepers and self._sleepers[0][0] <= target:
|
||||
deadline, _, future = heapq.heappop(self._sleepers)
|
||||
self._now = max(self._now, deadline)
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
await self._settle()
|
||||
self._now = target
|
||||
await self._settle()
|
||||
|
||||
@property
|
||||
def pending_timers(self) -> int:
|
||||
return len(self._sleepers)
|
||||
|
||||
async def _settle(self) -> None:
|
||||
# Give woken tasks a chance to run, then let their events be handled.
|
||||
await asyncio.sleep(0)
|
||||
if self._idle is not None:
|
||||
await self._idle()
|
||||
await asyncio.sleep(0)
|
||||
128
python-backend/musicmouse/color.py
Normal file
128
python-backend/musicmouse/color.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Colour types shared by the LED wire format and the config schema.
|
||||
|
||||
Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config`
|
||||
can validate colours without importing anything device-related.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["ColorHSV", "ColorRGBW", "parse_color"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ColorRGBW:
|
||||
"""An RGBW colour with all channels normalised to ``0.0 .. 1.0``."""
|
||||
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
w: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w))
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
if not self.is_valid:
|
||||
raise ValueError(f"Channel values must be within 0..1, got {self!r}")
|
||||
return struct.pack(
|
||||
"<BBBB",
|
||||
int(self.r * 255),
|
||||
int(self.g * 255),
|
||||
int(self.b * 255),
|
||||
int(self.w * 255),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> ColorRGBW:
|
||||
r, g, b, w = struct.unpack("<BBBB", data)
|
||||
return cls(r / 255, g / 255, b / 255, w / 255)
|
||||
|
||||
def __mul__(self, scale: float) -> ColorRGBW:
|
||||
if not 0 <= scale <= 1:
|
||||
raise ValueError(f"Scale must be within 0..1, got {scale}")
|
||||
return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale)
|
||||
|
||||
def without_white_channel(self) -> ColorRGBW:
|
||||
"""Fold the white channel into RGB, for strips driven without a W channel."""
|
||||
r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b))
|
||||
return ColorRGBW(r, g, b, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ColorHSV:
|
||||
"""Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``."""
|
||||
|
||||
h: float
|
||||
s: float
|
||||
v: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
||||
|
||||
@staticmethod
|
||||
def from_rgb(rgb: ColorRGBW) -> ColorHSV:
|
||||
h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
||||
return ColorHSV(h * 360, s, v)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
if not self.is_valid:
|
||||
raise ValueError(f"Out-of-range HSV colour {self!r}")
|
||||
return struct.pack("<fff", self.h, self.s, self.v)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> ColorHSV:
|
||||
return cls(*struct.unpack("<fff", data))
|
||||
|
||||
|
||||
def parse_color(value: str | ColorRGBW) -> ColorRGBW:
|
||||
"""Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour.
|
||||
|
||||
Raises:
|
||||
ValueError: with a message naming the accepted formats.
|
||||
"""
|
||||
if isinstance(value, ColorRGBW):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"expected a colour string, got {type(value).__name__}")
|
||||
|
||||
text = value.strip()
|
||||
if text.startswith("#"):
|
||||
digits = text[1:]
|
||||
if len(digits) != 6:
|
||||
raise ValueError(
|
||||
f"unrecognized color format {value!r} "
|
||||
f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})"
|
||||
)
|
||||
try:
|
||||
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)"
|
||||
) from None
|
||||
return ColorRGBW(r, g, b, 0)
|
||||
|
||||
if text.startswith("w"):
|
||||
digits = text[1:]
|
||||
try:
|
||||
white = int(digits, 16)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"unrecognized color format {value!r} (expected 'wNN' with hex digits)"
|
||||
) from None
|
||||
if not 0 <= white <= 255:
|
||||
raise ValueError(f"white value in {value!r} must be within 00..ff")
|
||||
return ColorRGBW(0, 0, 0, white / 255)
|
||||
|
||||
raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')")
|
||||
522
python-backend/musicmouse/config.py
Normal file
522
python-backend/musicmouse/config.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""Config schema and loading.
|
||||
|
||||
Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is
|
||||
silently ignored is worse than a startup failure), and every problem in the file is
|
||||
reported at once rather than one per run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PlainValidator,
|
||||
ValidationError,
|
||||
ValidationInfo,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.error import YAMLError
|
||||
|
||||
from musicmouse.color import ColorRGBW, parse_color
|
||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"SIMULATE",
|
||||
"ClockConfig",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"Digit",
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"FigureSelectionConfig",
|
||||
"GeneralConfig",
|
||||
"HaConfig",
|
||||
"HaDeviceConfig",
|
||||
"LibraryConfig",
|
||||
"LircConfig",
|
||||
"MqttConfig",
|
||||
"RemoteSlotConfig",
|
||||
"TippenConfig",
|
||||
"WebConfig",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||
|
||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||
|
||||
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
|
||||
#: error, so a config that lost a line fails loudly instead of booting into a silent
|
||||
#: mouse that looks like it is working.
|
||||
SIMULATE: Final = "simulate"
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised with an already human-readable, multi-line message."""
|
||||
|
||||
|
||||
def _parse_tag_id(value: Any) -> bytes:
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
else:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"expected a hex string, got {type(value).__name__}")
|
||||
text = value.strip().replace(":", "").replace(" ", "")
|
||||
try:
|
||||
raw = bytes.fromhex(text)
|
||||
except ValueError:
|
||||
raise ValueError(f"{value!r} is not a valid hex string") from None
|
||||
if len(raw) != RFID_TAG_LENGTH:
|
||||
raise ValueError(
|
||||
f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), "
|
||||
f"got {len(raw)} ({raw.hex()!r})"
|
||||
)
|
||||
if raw == NO_FIGURE_TAG:
|
||||
raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'")
|
||||
return raw
|
||||
|
||||
|
||||
Color = Annotated[ColorRGBW, PlainValidator(parse_color)]
|
||||
TagId = Annotated[bytes, PlainValidator(_parse_tag_id)]
|
||||
|
||||
_COLOR_ROLES = ("primary", "secondary", "bg", "accent")
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class FigureColors(_Strict):
|
||||
"""The four colours of a figure, given in config as a list of colour strings."""
|
||||
|
||||
primary: Color
|
||||
secondary: Color
|
||||
bg: Color
|
||||
accent: Color
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_sequence(cls, data: Any) -> Any:
|
||||
if isinstance(data, (list, tuple)):
|
||||
if len(data) != len(_COLOR_ROLES):
|
||||
raise ValueError(
|
||||
f"expected exactly {len(_COLOR_ROLES)} colors "
|
||||
f"({', '.join(_COLOR_ROLES)}), got {len(data)}"
|
||||
)
|
||||
return dict(zip(_COLOR_ROLES, data, strict=True))
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_folder(
|
||||
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
|
||||
) -> Path:
|
||||
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||
context = info.context or {}
|
||||
base = context.get("config_dir")
|
||||
if base is not None and not folder.is_absolute():
|
||||
folder = (Path(base) / folder).resolve()
|
||||
if must_exist and context.get("check_paths", True):
|
||||
exists = folder.is_file() if kind == "file" else folder.is_dir()
|
||||
if not exists:
|
||||
noun = "file" if kind == "file" else "directory"
|
||||
raise ValueError(f"no such {noun}: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
class MqttConfig(_Strict):
|
||||
server: str
|
||||
port: int = Field(default=1883, ge=1, le=65535)
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
base_topic: str = "musicmouse"
|
||||
discovery_prefix: str = "homeassistant"
|
||||
device_id: str = "musicmouse"
|
||||
device_name: str = "Music Mouse"
|
||||
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||
|
||||
|
||||
class LircConfig(_Strict):
|
||||
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
|
||||
|
||||
Omit the whole section to run without an IR remote.
|
||||
"""
|
||||
|
||||
host: str
|
||||
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
|
||||
#: default is 8765, so this is worth overriding rather than assuming.
|
||||
port: int = Field(default=2222, ge=1, le=65535)
|
||||
#: Only button events from this remote are acted on - other remotes registered
|
||||
#: with the same lircd (an LED remote, say) are ignored.
|
||||
#: Compared case-insensitively: irserver lowercases what lircd capitalises.
|
||||
remote_name: str = "Hauppauge"
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
|
||||
class ScreenConfig(_Strict):
|
||||
"""The monitor attached to the device, switched from the IR remote's power key.
|
||||
|
||||
Omit the whole section on a device without one: the key then does nothing.
|
||||
"""
|
||||
|
||||
#: Run on every press, split shell-style but not through a shell. On musicdolphin
|
||||
#: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is
|
||||
#: on and flips it - the backend keeps no screen state of its own.
|
||||
toggle_command: str
|
||||
#: Switches the monitor off unconditionally, for the idle auto-off. Without it
|
||||
#: there is no auto-off.
|
||||
off_command: str | None = None
|
||||
#: Switches it back on when someone is about again after an auto-off.
|
||||
on_command: str | None = None
|
||||
#: Minutes without music or anyone at the device before ``off_command`` runs.
|
||||
#: 0 means never. Parent mode edits this.
|
||||
auto_off_minutes: int = Field(default=15, ge=0)
|
||||
timeout: float = Field(default=5.0, gt=0)
|
||||
|
||||
|
||||
class ClockConfig(_Strict):
|
||||
"""The clock overlay's automatic bus countdown, and whose school days it follows.
|
||||
|
||||
Always present: leave the section out for a Bavarian school bus at 7:25.
|
||||
"""
|
||||
|
||||
#: ISO 3166-2 code the school and public holidays are looked up for, as the
|
||||
#: OpenHolidays API knows it. A city-level code (``DE-BY-AU``) also counts that
|
||||
#: city's own holidays; the state's are always included.
|
||||
subdivision: str = Field(default="DE-BY", pattern=r"^[A-Z]{2}-[A-Z0-9]+(-[A-Z0-9]+)?$")
|
||||
#: When the bus leaves, on school days only.
|
||||
bus_time: str = Field(default="07:25", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
#: How long before ``bus_time`` the countdown appears.
|
||||
bus_lead_minutes: int = Field(default=60, ge=1, le=240)
|
||||
|
||||
|
||||
class LibraryConfig(_Strict):
|
||||
"""Where the music lives.
|
||||
|
||||
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hörbücher``,
|
||||
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||
:mod:`musicmouse.library.sections`.
|
||||
"""
|
||||
|
||||
root: Path
|
||||
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||
cache: Path = Path(".musicmouse-cache")
|
||||
#: How many tracks background analysis may work on at once, each in its own worker
|
||||
#: process. Omit for one per core bar one (see
|
||||
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
|
||||
#: analysis to a single process on a machine that has other work to do.
|
||||
analysis_workers: int | None = Field(default=None, ge=1)
|
||||
|
||||
@field_validator("root")
|
||||
@classmethod
|
||||
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=True)
|
||||
|
||||
@field_validator("cache")
|
||||
@classmethod
|
||||
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
@property
|
||||
def figure_folder(self) -> Path:
|
||||
return self.root / "Figuren"
|
||||
|
||||
|
||||
class WebConfig(_Strict):
|
||||
"""The web front-end. Omit the whole section to run without it."""
|
||||
|
||||
#: A LAN appliance with no auth; binding to all interfaces is the point.
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8080, ge=1, le=65535)
|
||||
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
|
||||
static_dir: Path | None = None
|
||||
|
||||
@field_validator("static_dir")
|
||||
@classmethod
|
||||
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
|
||||
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
|
||||
class HaDeviceConfig(_Strict):
|
||||
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
|
||||
|
||||
entity_id: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class HaConfig(_Strict):
|
||||
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||
|
||||
The browser only gets the two ordered lists. Every state read and service call goes
|
||||
through the backend's ``/api/ha/...`` proxy, which attaches the token - so the token
|
||||
never leaves the backend. The proxy itself is unauthenticated, though: anything on
|
||||
the LAN that can reach musicmouse can use it. See config.yml.example.
|
||||
"""
|
||||
|
||||
url: str
|
||||
token: str
|
||||
#: Order is preserved and drives the device card grid on the room page.
|
||||
devices: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
#: Order is preserved and drives the scene pill row on the room page.
|
||||
scenes: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_something_configured(self) -> Self:
|
||||
if not self.devices and not self.scenes:
|
||||
raise ValueError("configure at least one device or scene, or omit the ha section")
|
||||
return self
|
||||
|
||||
|
||||
class TippenConfig(_Strict):
|
||||
"""The typing game. Omit the whole section to run without it.
|
||||
|
||||
The curriculum is content, not device settings, so it lives in its own file
|
||||
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
|
||||
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
|
||||
name next to ``config.yml`` if not given a folder of its own.
|
||||
"""
|
||||
|
||||
curriculum_file: Path
|
||||
progress_file: Path = Path("tippen-progress.json")
|
||||
|
||||
@field_validator("curriculum_file")
|
||||
@classmethod
|
||||
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=True, kind="file")
|
||||
|
||||
@field_validator("progress_file")
|
||||
@classmethod
|
||||
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||
|
||||
|
||||
class FigureSelectionConfig(_Strict):
|
||||
"""Overrides for the LED ring's album picker, for tuning it on the real device.
|
||||
|
||||
Temporary by design: every value defaults to a constant in
|
||||
:mod:`musicmouse.reactions.selection`, which is where the ones that look right
|
||||
belong once they have been found. ``None`` means "use that constant".
|
||||
"""
|
||||
|
||||
#: Seconds without a turn before the picker closes by itself.
|
||||
timeout: float | None = Field(default=None, gt=0)
|
||||
#: Where on the ring the first album's section starts, in degrees from LED 0.
|
||||
offset_degrees: float | None = None
|
||||
#: 1 or -1: which way round the ring the sections follow each other.
|
||||
ring_direction: Literal[1, -1] | None = None
|
||||
#: 1 or -1: which way the knob has to turn to move to the next album.
|
||||
knob_direction: Literal[1, -1] | None = None
|
||||
#: Dark LEDs between neighbouring sections.
|
||||
gap_leds: int | None = Field(default=None, ge=0, le=10)
|
||||
#: Brightness of the sections that are not selected, 0..1.
|
||||
dim_factor: float | None = Field(default=None, ge=0, le=1)
|
||||
#: How far the selected section swings back and forth, in LEDs.
|
||||
wobble_amplitude: float | None = Field(default=None, ge=0)
|
||||
#: How long one swing back and forth takes.
|
||||
wobble_period_ms: float | None = Field(default=None, gt=0)
|
||||
#: How long the highlight takes to glide to the next section.
|
||||
slide_ms: float | None = Field(default=None, ge=0)
|
||||
#: How far a section's edges shift towards its neighbours' colour, 0..0.5.
|
||||
edge_blend: float | None = Field(default=None, ge=0, le=0.5)
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
library: LibraryConfig
|
||||
|
||||
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
|
||||
#: web front-end is a complete way to drive the player on its own. Required.
|
||||
serial_port: str
|
||||
baudrate: int = Field(default=115200, gt=0)
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
|
||||
#: ``"simulate"`` for a player that makes no sound. Required.
|
||||
alsa_device: str
|
||||
|
||||
mqtt: MqttConfig | None = None
|
||||
web: WebConfig | None = None
|
||||
ha: HaConfig | None = None
|
||||
lirc: LircConfig | None = None
|
||||
screen: ScreenConfig | None = None
|
||||
tippen: TippenConfig | None = None
|
||||
clock: ClockConfig = Field(default_factory=ClockConfig)
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
initial_volume: int = Field(default=50, ge=0, le=200)
|
||||
volume_increment: int = Field(default=5, ge=1, le=100)
|
||||
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
||||
#: Loudness of the UI sounds - the screen's blubb, the parents' timer, the album
|
||||
#: picker's clicks - relative to the music, 0..100 %. Editable in parent mode.
|
||||
effects_volume: int = Field(default=100, ge=0, le=100)
|
||||
|
||||
figure_selection: FigureSelectionConfig = Field(default_factory=FigureSelectionConfig)
|
||||
|
||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||
|
||||
#: How many episodes of each podcast show to keep, newest first. A show that has
|
||||
#: published for years grows without bound and will eventually fill the device's SD
|
||||
#: card. ``null`` keeps every episode, and minding the free space is then on you.
|
||||
#:
|
||||
#: Lowering this *deletes* the episodes that fall outside the window on the next
|
||||
#: poll, and an episode that has aged out of its feed cannot be fetched again.
|
||||
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
|
||||
|
||||
@property
|
||||
def serial_simulated(self) -> bool:
|
||||
return self.serial_port == SIMULATE
|
||||
|
||||
@property
|
||||
def audio_simulated(self) -> bool:
|
||||
return self.alsa_device == SIMULATE
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_volumes(self) -> Self:
|
||||
if self.min_volume > self.max_volume:
|
||||
raise ValueError(
|
||||
f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})"
|
||||
)
|
||||
if not self.min_volume <= self.initial_volume <= self.max_volume:
|
||||
raise ValueError(
|
||||
f"initial_volume ({self.initial_volume}) must lie between "
|
||||
f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class FigureConfig(_Strict):
|
||||
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||
id: TagId
|
||||
colors: FigureColors
|
||||
#: What this figure holds. Unlike the other shelves a figure folder is named after
|
||||
#: the figurine rather than its contents, so nothing on disk says whether it is an
|
||||
#: album or an audiobook - and the browse view draws the two differently.
|
||||
kind: Literal["music", "book"] = "music"
|
||||
|
||||
|
||||
class RemoteSlotConfig(_Strict):
|
||||
"""What a number key on the IR remote plays.
|
||||
|
||||
``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
|
||||
from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
|
||||
``Album.series``); resolved to that show's newest episode fresh on every press,
|
||||
since a podcast show is not itself one playable thing in this library - each
|
||||
episode is its own album.
|
||||
"""
|
||||
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
#: ``"album"`` only: start at this track instead of the first, then carry on with
|
||||
#: the rest of the album - one song assigned to a key.
|
||||
track_index: int = Field(default=0, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_track_index(self) -> Self:
|
||||
if self.target_kind == "series" and self.track_index:
|
||||
raise ValueError("track_index only applies to album slots")
|
||||
return self
|
||||
|
||||
|
||||
class Config(_Strict):
|
||||
general: GeneralConfig
|
||||
figures: dict[str, FigureConfig] = Field(min_length=1)
|
||||
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
|
||||
#: assignments, and that is not an error.
|
||||
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_unique_tag_ids(self) -> Self:
|
||||
seen: dict[bytes, str] = {}
|
||||
for name, figure in self.figures.items():
|
||||
if (other := seen.get(figure.id)) is not None:
|
||||
raise ValueError(
|
||||
f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}"
|
||||
)
|
||||
seen[figure.id] = name
|
||||
return self
|
||||
|
||||
@property
|
||||
def tag_map(self) -> dict[bytes, str]:
|
||||
"""Tag id -> figure name, as handed to the device."""
|
||||
return {figure.id: name for name, figure in self.figures.items()}
|
||||
|
||||
@property
|
||||
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
|
||||
"""Figure name -> what it holds, as handed to the library scanner."""
|
||||
return {name: figure.kind for name, figure in self.figures.items()}
|
||||
|
||||
def folder_for(self, figure: str) -> Path:
|
||||
return self.general.library.figure_folder / figure
|
||||
|
||||
|
||||
def format_validation_error(error: ValidationError) -> str:
|
||||
"""Render a pydantic error as one short ``path: message`` line per problem."""
|
||||
lines: list[str] = []
|
||||
for entry in error.errors():
|
||||
location = ".".join(
|
||||
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
|
||||
).replace(".[", "[")
|
||||
message = entry["msg"]
|
||||
for prefix in ("Value error, ", "Assertion failed, "):
|
||||
message = message.removeprefix(prefix)
|
||||
if entry["type"] == "extra_forbidden":
|
||||
message = "unknown option (check the spelling against config.yml.example)"
|
||||
elif entry["type"] == "missing":
|
||||
message = "required (see config.yml.example)"
|
||||
lines.append(f" {location or '<root>'}: {message}")
|
||||
plural = "s" if len(lines) != 1 else ""
|
||||
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def load_config(path: Path, *, check_paths: bool = True) -> Config:
|
||||
"""Load and validate a config file.
|
||||
|
||||
Raises:
|
||||
ConfigError: with a message that can be printed straight to the terminal.
|
||||
"""
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
raise ConfigError(
|
||||
f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}"
|
||||
)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc
|
||||
|
||||
try:
|
||||
data = YAML(typ="safe").load(text)
|
||||
except YAMLError as exc:
|
||||
raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc
|
||||
|
||||
if data is None:
|
||||
raise ConfigError(f"{path} is empty")
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(
|
||||
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
context = {"config_dir": path.parent, "check_paths": check_paths}
|
||||
try:
|
||||
config = Config.model_validate(data, context=context)
|
||||
except ValidationError as exc:
|
||||
raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc
|
||||
|
||||
if check_paths:
|
||||
for name in config.figures:
|
||||
folder = config.folder_for(name)
|
||||
if not folder.is_dir():
|
||||
_log.warning("Figure %r has no media folder at %s", name, folder)
|
||||
return config
|
||||
1
python-backend/musicmouse/devices/__init__.py
Normal file
1
python-backend/musicmouse/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Objects that own a piece of hardware: the mouse itself and the audio player."""
|
||||
208
python-backend/musicmouse/devices/mouse.py
Normal file
208
python-backend/musicmouse/devices/mouse.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""The mouse itself: the object that talks to the firmware.
|
||||
|
||||
It owns the physical state - which figure is on the reader, how bright the button
|
||||
backlights are, which effect each LED zone is showing - and it is the single writer to
|
||||
all three LED zones. "Last write wins" is therefore a well-defined rule: whoever sets a
|
||||
zone last, whether a figure animation or an MQTT command, is what the strip shows.
|
||||
|
||||
Every write emits :class:`~musicmouse.events.LedEffectChanged`, so front-ends can
|
||||
publish the strip's real state instead of echoing their own commands back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.devices.transport import Transport
|
||||
from musicmouse.devices.wire import (
|
||||
FirmwareLog,
|
||||
FrameDecoder,
|
||||
ProtocolError,
|
||||
UnsupportedEffectError,
|
||||
encode_button_brightness,
|
||||
encode_effect,
|
||||
)
|
||||
from musicmouse.effects import OFF, LedEffect
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
ButtonEvent,
|
||||
ConnectionChanged,
|
||||
EventSource,
|
||||
InputEvent,
|
||||
LedEffectChanged,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
SelectorPressed,
|
||||
SelectorTurned,
|
||||
)
|
||||
from musicmouse.hardware import NO_FIGURE_TAG, Button, ButtonAction, LedZone, RotaryDirection
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["MusicMouseDevice"]
|
||||
|
||||
_BACKLIT_BUTTONS = (Button.LEFT, Button.RIGHT)
|
||||
|
||||
|
||||
class MusicMouseDevice:
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
transport: Transport,
|
||||
tag_map: dict[bytes, str],
|
||||
*,
|
||||
port: str = "",
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._transport = transport
|
||||
self._tag_map = dict(tag_map)
|
||||
self._decoder = FrameDecoder()
|
||||
self.port = port
|
||||
|
||||
self._active_figure: str | None = None
|
||||
self._button_brightness: float = 0.0
|
||||
self._effects: dict[LedZone, LedEffect] = {}
|
||||
self._knob_captured = False
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def active_figure(self) -> str | None:
|
||||
"""The figure currently on the reader, or ``None`` if there is none."""
|
||||
return self._active_figure
|
||||
|
||||
@property
|
||||
def button_led_brightness(self) -> float:
|
||||
return self._button_brightness
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._transport.connected
|
||||
|
||||
def effect(self, zone: LedZone) -> LedEffect | None:
|
||||
return self._effects.get(zone)
|
||||
|
||||
@property
|
||||
def knob_captured(self) -> bool:
|
||||
return self._knob_captured
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def set_effect(
|
||||
self, zone: LedZone, effect: LedEffect, *, origin: EventSource = "system"
|
||||
) -> None:
|
||||
"""Show ``effect`` on ``zone``. The most recent call wins."""
|
||||
try:
|
||||
frame = encode_effect(zone, effect)
|
||||
except UnsupportedEffectError as exc:
|
||||
_log.error("%s", exc)
|
||||
return
|
||||
|
||||
self._effects[zone] = effect
|
||||
self._transport.write(frame)
|
||||
self._bus.emit(
|
||||
LedEffectChanged(zone=zone, effect=effect, origin=origin, source="device")
|
||||
)
|
||||
|
||||
def set_button_brightness(self, brightness: float, *, origin: EventSource = "system") -> None:
|
||||
"""Set both prev/next button backlights (``0..1``)."""
|
||||
brightness = min(1.0, max(0.0, brightness))
|
||||
self._button_brightness = brightness
|
||||
for button in _BACKLIT_BUTTONS:
|
||||
self._transport.write(encode_button_brightness(button, brightness))
|
||||
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
||||
|
||||
def capture_knob(self, captured: bool) -> None:
|
||||
"""Hand the rotary encoder to the ring's album picker, or give it back.
|
||||
|
||||
While captured, turns and presses come out as :class:`SelectorTurned` and
|
||||
:class:`SelectorPressed` instead of :class:`RotaryTurned` and
|
||||
:class:`ButtonEvent`, so the volume and Home Assistant never see them. Done here,
|
||||
as the frames are decoded, rather than by each listener checking a flag: the
|
||||
press that closes the picker would otherwise race whoever handles it after.
|
||||
"""
|
||||
self._knob_captured = captured
|
||||
|
||||
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
||||
for zone in LedZone:
|
||||
self.set_effect(zone, OFF(), origin=origin)
|
||||
self.set_button_brightness(0.0, origin=origin)
|
||||
|
||||
# ------------------------------------------------------------- link callbacks
|
||||
|
||||
def on_connected(self) -> None:
|
||||
"""Re-apply memorized state, so a reconnect is invisible from the outside."""
|
||||
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
|
||||
|
||||
for zone, effect in self._effects.items():
|
||||
self._transport.write(encode_effect(zone, effect))
|
||||
for button in _BACKLIT_BUTTONS:
|
||||
self._transport.write(encode_button_brightness(button, self._button_brightness))
|
||||
if self._effects:
|
||||
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
|
||||
|
||||
def on_disconnected(self, _reason: str = "") -> None:
|
||||
# SerialLink already logs the reason; the event only says the link is down.
|
||||
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
"""Hand bytes from the link to the decoder and publish what comes out."""
|
||||
self._decoder.push(data)
|
||||
while True:
|
||||
try:
|
||||
item = self._decoder.take()
|
||||
except ProtocolError as exc:
|
||||
_log.warning("Discarding bad frame from firmware: %s", exc)
|
||||
continue
|
||||
if item is None:
|
||||
return
|
||||
|
||||
if isinstance(item, FirmwareLog):
|
||||
if item.text:
|
||||
_log.info("[firmware] %s", item.text)
|
||||
else:
|
||||
self._publish(item)
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
|
||||
def _publish(self, event: InputEvent) -> None:
|
||||
if isinstance(event, RfidTokenRead):
|
||||
self._publish_tag_read(event)
|
||||
elif self._knob_captured and isinstance(event, RotaryTurned):
|
||||
if event.direction is not RotaryDirection.NONE:
|
||||
# The first turn after boot reports no increment, but it was a detent.
|
||||
steps = abs(event.increment) or 1
|
||||
if event.direction is RotaryDirection.DOWN:
|
||||
steps = -steps
|
||||
self._bus.emit(SelectorTurned(steps=steps, source="device"))
|
||||
elif (
|
||||
self._knob_captured
|
||||
and isinstance(event, ButtonEvent)
|
||||
and event.button is Button.ROTARY
|
||||
):
|
||||
if event.action is ButtonAction.PRESSED:
|
||||
self._bus.emit(SelectorPressed(source="device"))
|
||||
else:
|
||||
self._bus.emit(event)
|
||||
|
||||
def _publish_tag_read(self, event: RfidTokenRead) -> None:
|
||||
if event.tag_id == NO_FIGURE_TAG:
|
||||
figure, known = None, True
|
||||
elif (name := self._tag_map.get(event.tag_id)) is not None:
|
||||
figure, known = name, True
|
||||
else:
|
||||
figure, known = None, False
|
||||
_log.warning("Unknown RFID tag %s - not configured as a figure", event.tag_id.hex())
|
||||
|
||||
self._bus.emit(replace(event, figure=figure, known=known))
|
||||
if not known:
|
||||
# Leave the active figure alone: an unreadable tag is not a removal.
|
||||
return
|
||||
|
||||
previous, self._active_figure = self._active_figure, figure
|
||||
if previous != figure:
|
||||
self._bus.emit(
|
||||
ActiveFigureChanged(figure=figure, previous=previous, source="device")
|
||||
)
|
||||
22
python-backend/musicmouse/devices/null_transport.py
Normal file
22
python-backend/musicmouse/devices/null_transport.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""A transport with nothing on the other end.
|
||||
|
||||
The web front-end is a complete way to drive the mouse, so the backend has to be
|
||||
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
|
||||
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
|
||||
teaching every reaction to cope with its absence, the device is built as usual against
|
||||
a transport that drops what it is handed. Lighting reactions still run; their bytes go
|
||||
nowhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["NullTransport"]
|
||||
|
||||
|
||||
class NullTransport:
|
||||
def write(self, data: bytes) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return False
|
||||
365
python-backend/musicmouse/devices/player.py
Normal file
365
python-backend/musicmouse/devices/player.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Audio playback, behind a protocol.
|
||||
|
||||
:class:`VlcPlayer` is the only real implementation; the simulator supplies another.
|
||||
libVLC fires its callbacks on its own thread, so every one of them goes through
|
||||
``bus.emit()``, which hops back onto the event loop. The old code called straight into
|
||||
the serial transport from that thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.events import (
|
||||
EventSource,
|
||||
PlaybackChanged,
|
||||
PlaylistFinished,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.media import Playlist, Track
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmouse.config import GeneralConfig
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Player", "PlayerBase", "VlcPlayer"]
|
||||
|
||||
|
||||
class Player(Protocol):
|
||||
"""What reactions and front-ends are allowed to do with the audio player."""
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool: ...
|
||||
@property
|
||||
def volume(self) -> int: ...
|
||||
@property
|
||||
def playlist(self) -> Playlist | None: ...
|
||||
@property
|
||||
def track_index(self) -> int: ...
|
||||
@property
|
||||
def current_track(self) -> Track | None: ...
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""Seconds into the current track. ``0.0`` when nothing is loaded.
|
||||
|
||||
Read on demand rather than announced: a progress bar wants this twice a second,
|
||||
and an event at that rate would flood the bus, the MQTT service and the log for
|
||||
the benefit of one front-end.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Length of the current track in seconds, or ``0.0`` when unknown."""
|
||||
...
|
||||
|
||||
def set_playlist(self, playlist: Playlist) -> None: ...
|
||||
def play(self) -> None: ...
|
||||
def play_from_start(self) -> None: ...
|
||||
def play_track(self, index: int) -> None: ...
|
||||
def pause(self) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
def next_track(self) -> None: ...
|
||||
def previous_track(self) -> None: ...
|
||||
def seek(self, position: float) -> None: ...
|
||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
|
||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
|
||||
def set_volume_limits(self, minimum: int, maximum: int) -> None: ...
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Long-running task, if the implementation needs one."""
|
||||
...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class PlayerBase:
|
||||
"""Volume clamping, playlist bookkeeping and state events, shared by the
|
||||
real and the simulated player."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
*,
|
||||
min_volume: int = 0,
|
||||
max_volume: int = 100,
|
||||
initial_volume: int = 50,
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._min_volume = min_volume
|
||||
self._max_volume = max_volume
|
||||
self._volume = self._clamp(initial_volume)
|
||||
self._playlist: Playlist | None = None
|
||||
self._index = 0
|
||||
self._playing = False
|
||||
self._playlist_changed = False
|
||||
|
||||
@classmethod
|
||||
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
|
||||
return {
|
||||
"min_volume": config.min_volume,
|
||||
"max_volume": config.max_volume,
|
||||
"initial_volume": config.initial_volume,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
return self._playing
|
||||
|
||||
@property
|
||||
def volume(self) -> int:
|
||||
return self._volume
|
||||
|
||||
@property
|
||||
def playlist(self) -> Playlist | None:
|
||||
return self._playlist
|
||||
|
||||
@property
|
||||
def track_index(self) -> int:
|
||||
return self._index
|
||||
|
||||
@property
|
||||
def current_track(self) -> Track | None:
|
||||
if self._playlist is None or not 0 <= self._index < len(self._playlist):
|
||||
return None
|
||||
return self._playlist[self._index]
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
||||
clamped = self._clamp(volume)
|
||||
if clamped == self._volume:
|
||||
return
|
||||
self._volume = clamped
|
||||
self._apply_volume(clamped)
|
||||
self._announce_volume(source)
|
||||
|
||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
||||
self.set_volume(self._volume + delta, source=source)
|
||||
|
||||
def set_volume_limits(self, minimum: int, maximum: int) -> None:
|
||||
"""Re-clamp to a new allowed range, and pull the current volume into it.
|
||||
|
||||
Parent mode edits these while the mouse is playing, so they cannot only be
|
||||
constructor arguments.
|
||||
"""
|
||||
self._min_volume = minimum
|
||||
self._max_volume = maximum
|
||||
self.set_volume(self._volume)
|
||||
|
||||
def _apply_volume(self, volume: int) -> None:
|
||||
"""Push the new volume at whatever actually makes sound. No-op by default."""
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
|
||||
def _clamp(self, volume: int) -> int:
|
||||
# `if self._min_volume and ...` in the old code silently ignored min_volume: 0,
|
||||
# which is what config.yml.example shipped with.
|
||||
return max(self._min_volume, min(self._max_volume, volume))
|
||||
|
||||
def _set_playing(self, playing: bool, *, figure: str | None = None) -> None:
|
||||
if playing == self._playing:
|
||||
return
|
||||
self._playing = playing
|
||||
self._playlist_changed = False
|
||||
self._bus.emit(
|
||||
PlaybackChanged(
|
||||
playing=playing, figure=figure, playlist=self._playlist, source="player"
|
||||
)
|
||||
)
|
||||
|
||||
def _set_index(self, index: int) -> None:
|
||||
# A playlist swap while already playing resets the index to 0 without going
|
||||
# through here (see `_load_playlist`), so the following `play_track(0)` looks
|
||||
# like a no-op index change. Force it through in that case - `_set_playing`
|
||||
# will not itself announce anything, since it was already playing before and
|
||||
# after. Starting from idle needs no such push: `_set_playing`'s own True
|
||||
# transition already covers the broadcast.
|
||||
force = self._playlist_changed and self._playing
|
||||
if index == self._index and not force:
|
||||
return
|
||||
self._playlist_changed = False
|
||||
self._index = index
|
||||
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
|
||||
|
||||
def _load_playlist(self, playlist: Playlist) -> None:
|
||||
"""Bookkeeping shared by every player's ``set_playlist``."""
|
||||
self._playlist = playlist
|
||||
self._index = 0
|
||||
self._playlist_changed = True
|
||||
|
||||
def _announce_volume(self, source: EventSource = "player") -> None:
|
||||
self._bus.emit(VolumeChanged(volume=self._volume, source=source))
|
||||
|
||||
def _announce_playlist_finished(self) -> None:
|
||||
self._bus.emit(PlaylistFinished(source="player"))
|
||||
|
||||
async def run(self) -> None: # pragma: no cover - overridden where needed
|
||||
return
|
||||
|
||||
def close(self) -> None: # pragma: no cover - overridden where needed
|
||||
return
|
||||
|
||||
|
||||
class VlcPlayer(PlayerBase):
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
*,
|
||||
alsa_device: str | None = None,
|
||||
min_volume: int = 0,
|
||||
max_volume: int = 100,
|
||||
initial_volume: int = 50,
|
||||
poll_interval: float = 1.0,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume
|
||||
)
|
||||
# Imported here rather than at module scope: python-vlc loads libvlc eagerly,
|
||||
# and the simulator must run on machines without it.
|
||||
import vlc
|
||||
|
||||
self._vlc = vlc
|
||||
self._poll_interval = poll_interval
|
||||
self._clock = clock or RealClock()
|
||||
|
||||
args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
|
||||
self._instance = vlc.Instance(*args)
|
||||
self._list_player = self._instance.media_list_player_new()
|
||||
self._media_player = self._list_player.get_media_player()
|
||||
self._mrl_to_index: dict[str, int] = {}
|
||||
|
||||
self._attach_events()
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
# libVLC reports -1 for both of these until a media is actually opened.
|
||||
return max(0.0, float(self._media_player.get_time()) / 1000)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, float(self._media_player.get_length()) / 1000)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def set_playlist(self, playlist: Playlist) -> None:
|
||||
media_list = self._vlc.MediaList()
|
||||
self._mrl_to_index.clear()
|
||||
for index, track in enumerate(playlist.tracks):
|
||||
media = self._instance.media_new(str(track.path))
|
||||
media_list.add_media(media)
|
||||
self._mrl_to_index[media.get_mrl()] = index
|
||||
|
||||
self._list_player.set_media_list(media_list)
|
||||
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
|
||||
self._load_playlist(playlist)
|
||||
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
|
||||
|
||||
def play(self) -> None:
|
||||
if self._playing:
|
||||
return
|
||||
self._list_player.play()
|
||||
|
||||
def play_from_start(self) -> None:
|
||||
self.play_track(0)
|
||||
|
||||
def play_track(self, index: int) -> None:
|
||||
if self._playlist is None or not self._playlist:
|
||||
_log.warning("Nothing to play: the playlist is empty")
|
||||
return
|
||||
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._playing:
|
||||
return
|
||||
self._media_player.set_pause(1)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._list_player.stop()
|
||||
|
||||
def next_track(self) -> None:
|
||||
self._list_player.next()
|
||||
|
||||
def previous_track(self) -> None:
|
||||
self._list_player.previous()
|
||||
|
||||
def seek(self, position: float) -> None:
|
||||
self._media_player.set_time(int(max(0.0, position) * 1000))
|
||||
|
||||
def _apply_volume(self, volume: int) -> None:
|
||||
self._media_player.audio_set_volume(volume)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Poll for state libVLC does not reliably report by event."""
|
||||
while True:
|
||||
await self._clock.sleep(self._poll_interval)
|
||||
try:
|
||||
self._poll()
|
||||
except Exception: # pragma: no cover - defensive around a C library
|
||||
_log.exception("VLC poll failed")
|
||||
|
||||
def close(self) -> None:
|
||||
self._list_player.stop()
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
|
||||
def _poll(self) -> None:
|
||||
volume = self._media_player.audio_get_volume()
|
||||
if volume >= 0 and volume != self._volume:
|
||||
self._volume = volume
|
||||
self._announce_volume()
|
||||
self._set_playing(bool(self._list_player.is_playing()))
|
||||
self._sync_index()
|
||||
|
||||
def _sync_index(self) -> None:
|
||||
"""Read which track is playing back off libVLC.
|
||||
|
||||
Asking the media player which media it holds works on every build; the
|
||||
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
|
||||
int rather than a Media, and the index would then never move off zero.
|
||||
"""
|
||||
media = self._media_player.get_media()
|
||||
if media is None:
|
||||
return
|
||||
index = self._mrl_to_index.get(media.get_mrl())
|
||||
if index is not None:
|
||||
self._set_index(index)
|
||||
|
||||
def _attach_events(self) -> None:
|
||||
vlc = self._vlc
|
||||
player_events = self._media_player.event_manager()
|
||||
player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
|
||||
player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped)
|
||||
player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped)
|
||||
|
||||
list_events = self._list_player.event_manager()
|
||||
list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end)
|
||||
list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item)
|
||||
|
||||
# These four run on a libVLC thread. bus.emit() is the thread hop; nothing else
|
||||
# here may touch the loop.
|
||||
|
||||
def _on_playing(self, _event: Any) -> None:
|
||||
self._set_playing(True)
|
||||
|
||||
def _on_stopped(self, _event: Any) -> None:
|
||||
self._set_playing(False)
|
||||
|
||||
def _on_playlist_end(self, _event: Any) -> None:
|
||||
self._set_playing(False)
|
||||
self._announce_playlist_finished()
|
||||
|
||||
def _on_next_item(self, _event: Any) -> None:
|
||||
# The event says *when* to look; what it carries is not portable, so ignore it.
|
||||
self._sync_index()
|
||||
122
python-backend/musicmouse/devices/serial_link.py
Normal file
122
python-backend/musicmouse/devices/serial_link.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Serial transport with reconnect.
|
||||
|
||||
The old ``host_driver.py`` stopped the event loop when the USB cable was pulled and
|
||||
relied on systemd to restart the whole process. Here a dropped link is just a
|
||||
reconnect loop, and the device re-applies its LED state when it comes back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import serial_asyncio
|
||||
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["SerialLink"]
|
||||
|
||||
_READ_CHUNK = 1024
|
||||
|
||||
|
||||
class SerialLink:
|
||||
"""Owns the serial port and keeps trying to hold it open."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
baudrate: int = 115200,
|
||||
*,
|
||||
reconnect_interval: float = 5.0,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._reconnect_interval = reconnect_interval
|
||||
self._clock = clock or RealClock()
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
|
||||
self._on_data: Callable[[bytes], None] = lambda _data: None
|
||||
self._on_connect: Callable[[], None] | None = None
|
||||
self._on_disconnect: Callable[[str], None] | None = None
|
||||
|
||||
def attach(
|
||||
self,
|
||||
on_data: Callable[[bytes], None],
|
||||
*,
|
||||
on_connect: Callable[[], None] | None = None,
|
||||
on_disconnect: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Wire up the device. Separate from ``__init__`` because the device needs the
|
||||
link as its transport, so one of the two has to be built first."""
|
||||
self._on_data = on_data
|
||||
self._on_connect = on_connect
|
||||
self._on_disconnect = on_disconnect
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._writer is not None
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
writer = self._writer
|
||||
if writer is None:
|
||||
_log.debug("Dropping %d bytes: %s is not connected", len(data), self.port)
|
||||
return
|
||||
try:
|
||||
writer.write(data)
|
||||
except OSError as exc: # pragma: no cover - needs a real port dying mid-write
|
||||
_log.warning("Write to %s failed: %s", self.port, exc)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Connect, read until the link drops, wait, repeat. Runs until cancelled."""
|
||||
while True:
|
||||
reader = await self._connect()
|
||||
if reader is None:
|
||||
await self._clock.sleep(self._reconnect_interval)
|
||||
continue
|
||||
|
||||
reason = await self._pump(reader)
|
||||
|
||||
self._writer = None
|
||||
_log.warning("Lost connection to %s: %s", self.port, reason)
|
||||
if self._on_disconnect is not None:
|
||||
self._on_disconnect(reason)
|
||||
await self._clock.sleep(self._reconnect_interval)
|
||||
|
||||
async def _connect(self) -> asyncio.StreamReader | None:
|
||||
reader: asyncio.StreamReader
|
||||
writer: asyncio.StreamWriter
|
||||
try:
|
||||
reader, writer = await serial_asyncio.open_serial_connection(
|
||||
url=self.port, baudrate=self.baudrate
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
_log.warning(
|
||||
"Cannot open %s (%s); retrying in %gs",
|
||||
self.port,
|
||||
exc,
|
||||
self._reconnect_interval,
|
||||
)
|
||||
return None
|
||||
|
||||
self._writer = writer
|
||||
_log.info("Connected to firmware on %s at %d baud", self.port, self.baudrate)
|
||||
if self._on_connect is not None:
|
||||
self._on_connect()
|
||||
return reader
|
||||
|
||||
async def _pump(self, reader: asyncio.StreamReader) -> str:
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(_READ_CHUNK)
|
||||
if not data:
|
||||
return "port closed"
|
||||
self._on_data(data)
|
||||
except asyncio.CancelledError:
|
||||
self._writer = None
|
||||
raise
|
||||
except (OSError, asyncio.IncompleteReadError) as exc:
|
||||
return str(exc)
|
||||
21
python-backend/musicmouse/devices/transport.py
Normal file
21
python-backend/musicmouse/devices/transport.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""The seam between :class:`~musicmouse.devices.mouse.MusicMouseDevice` and the wire.
|
||||
|
||||
Kept free of any serial import so the simulator can substitute a transport without
|
||||
pulling in ``pyserial-asyncio``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
__all__ = ["Transport"]
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Send bytes to the firmware. Dropping them while disconnected is fine:
|
||||
the device re-applies its memorized state once the link is back."""
|
||||
...
|
||||
|
||||
@property
|
||||
def connected(self) -> bool: ...
|
||||
425
python-backend/musicmouse/devices/wire.py
Normal file
425
python-backend/musicmouse/devices/wire.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""The serial wire protocol, as a pure codec.
|
||||
|
||||
No I/O and no asyncio here, so the exact byte layout the firmware depends on can be
|
||||
pinned by ``tests/test_wire.py``. Everything must stay in sync with
|
||||
``esp-firmware/src/Messages.h``; that file is the authority.
|
||||
|
||||
Frames in both directions are ``uint32 magic | uint8 type | uint16 payload_size``
|
||||
followed by the payload, little-endian. The firmware also writes plain
|
||||
``Serial.println`` log text on the same link, so the decoder resynchronises on
|
||||
newlines and on the magic token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import TypeAlias
|
||||
|
||||
from musicmouse.effects import (
|
||||
EffectAlexaSwipeConfig,
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectSegmentSelect,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
LedEffect,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
ButtonEvent,
|
||||
InputEvent,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
TouchButtonPressed,
|
||||
TouchButtonReleased,
|
||||
)
|
||||
from musicmouse.hardware import (
|
||||
RFID_TAG_LENGTH,
|
||||
Button,
|
||||
ButtonAction,
|
||||
LedZone,
|
||||
RotaryDirection,
|
||||
TouchButton,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAGIC_FW_TO_HOST",
|
||||
"MAGIC_HOST_TO_FW",
|
||||
"Decoded",
|
||||
"FirmwareLog",
|
||||
"FrameDecoder",
|
||||
"HostCommand",
|
||||
"HostFrameDecoder",
|
||||
"MessageFwToHost",
|
||||
"MessageHostToFw",
|
||||
"ProtocolError",
|
||||
"SetButtonBrightness",
|
||||
"SetEffect",
|
||||
"UnsupportedEffectError",
|
||||
"encode_button_brightness",
|
||||
"encode_effect",
|
||||
"encode_input_event",
|
||||
]
|
||||
|
||||
MAGIC_HOST_TO_FW = 0x1D6379E3
|
||||
MAGIC_FW_TO_HOST = 0x10C65631
|
||||
|
||||
_HEADER = struct.Struct("<IBH")
|
||||
_HEADER_SIZE = _HEADER.size # 7
|
||||
_MAGIC_FW_BYTES = struct.pack("<I", MAGIC_FW_TO_HOST)
|
||||
|
||||
#: Give up on resynchronising rather than buffering forever on a wedged link.
|
||||
_MAX_BUFFER = 8192
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
"""A frame arrived that could not be interpreted."""
|
||||
|
||||
|
||||
class UnsupportedEffectError(Exception):
|
||||
"""The firmware has no message for this effect on this LED zone."""
|
||||
|
||||
|
||||
class MessageFwToHost(IntEnum):
|
||||
RFID_TOKEN_READ = 0
|
||||
ROTARY_ENCODER = 1
|
||||
TOUCH_BUTTON_PRESS = 2
|
||||
TOUCH_BUTTON_RELEASE = 3
|
||||
BUTTON_EVENT = 4
|
||||
|
||||
|
||||
class MessageHostToFw(IntEnum):
|
||||
LED_WHEEL_EFFECT_STATIC = 0
|
||||
LED_WHEEL_EFFECT_ALEXA_SWIPE = 1
|
||||
LED_WHEEL_EFFECT_CIRCULAR = 2
|
||||
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3
|
||||
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4
|
||||
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5
|
||||
|
||||
MOUSE_LED_EFFECT_STATIC = 6
|
||||
MOUSE_LED_EFFECT_CIRCULAR = 7
|
||||
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8
|
||||
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9
|
||||
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10
|
||||
|
||||
SHELF_LED_EFFECT_STATIC = 15
|
||||
SHELF_LED_EFFECT_CIRCULAR = 16
|
||||
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17
|
||||
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18
|
||||
SHELF_LED_EFFECT_REVERSE_SWIPE = 19
|
||||
SHELF_LED_EFFECT_STATIC_DETAILED = 20
|
||||
|
||||
PREV_BUTTON_LED = 21
|
||||
NEXT_BUTTON_LED = 22
|
||||
|
||||
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23
|
||||
|
||||
|
||||
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
|
||||
#: accepts AlexaSwipe and SegmentSelect, and only the shelf accepts StaticDetailed.
|
||||
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
||||
LedZone.RING: {
|
||||
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
|
||||
EffectAlexaSwipeConfig: MessageHostToFw.LED_WHEEL_EFFECT_ALEXA_SWIPE,
|
||||
EffectCircularConfig: MessageHostToFw.LED_WHEEL_EFFECT_CIRCULAR,
|
||||
EffectRandomTwoColorInterpolationConfig: (
|
||||
MessageHostToFw.LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
|
||||
),
|
||||
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
|
||||
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
|
||||
EffectSegmentSelect: MessageHostToFw.LED_WHEEL_EFFECT_SEGMENT_SELECT,
|
||||
},
|
||||
LedZone.MOUSE: {
|
||||
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
|
||||
EffectCircularConfig: MessageHostToFw.MOUSE_LED_EFFECT_CIRCULAR,
|
||||
EffectRandomTwoColorInterpolationConfig: (
|
||||
MessageHostToFw.MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
|
||||
),
|
||||
EffectSwipeAndChange: MessageHostToFw.MOUSE_LED_EFFECT_SWIPE_AND_CHANGE,
|
||||
EffectReverseSwipe: MessageHostToFw.MOUSE_LED_EFFECT_REVERSE_SWIPE,
|
||||
},
|
||||
LedZone.SHELF: {
|
||||
EffectStaticConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC,
|
||||
EffectCircularConfig: MessageHostToFw.SHELF_LED_EFFECT_CIRCULAR,
|
||||
EffectRandomTwoColorInterpolationConfig: (
|
||||
MessageHostToFw.SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
|
||||
),
|
||||
EffectSwipeAndChange: MessageHostToFw.SHELF_LED_EFFECT_SWIPE_AND_CHANGE,
|
||||
EffectReverseSwipe: MessageHostToFw.SHELF_LED_EFFECT_REVERSE_SWIPE,
|
||||
EffectStaticDetailedConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC_DETAILED,
|
||||
},
|
||||
}
|
||||
|
||||
_BUTTON_LED_IDS: dict[Button, MessageHostToFw] = {
|
||||
Button.LEFT: MessageHostToFw.PREV_BUTTON_LED,
|
||||
Button.RIGHT: MessageHostToFw.NEXT_BUTTON_LED,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirmwareLog:
|
||||
"""A ``Serial.println`` line from the firmware, interleaved with the frames."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
Decoded: TypeAlias = InputEvent | FirmwareLog
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- encoding
|
||||
|
||||
|
||||
def _frame(message: MessageHostToFw, payload: bytes) -> bytes:
|
||||
return _HEADER.pack(MAGIC_HOST_TO_FW, message, len(payload)) + payload
|
||||
|
||||
|
||||
def encode_effect(zone: LedZone, effect: LedEffect) -> bytes:
|
||||
"""Encode ``effect`` as a frame for ``zone``.
|
||||
|
||||
Raises:
|
||||
UnsupportedEffectError: if the firmware has no message for this combination.
|
||||
"""
|
||||
try:
|
||||
message = _EFFECT_IDS[zone][type(effect)]
|
||||
except KeyError:
|
||||
supported = ", ".join(sorted(cls.__name__ for cls in _EFFECT_IDS[zone]))
|
||||
raise UnsupportedEffectError(
|
||||
f"{type(effect).__name__} cannot be sent to the {zone} LEDs "
|
||||
f"(supported there: {supported})"
|
||||
) from None
|
||||
return _frame(message, effect.as_bytes())
|
||||
|
||||
|
||||
def encode_button_brightness(button: Button, brightness: float) -> bytes:
|
||||
"""Encode the backlight brightness (``0..1``) of the prev/next button."""
|
||||
if not 0 <= brightness <= 1:
|
||||
raise ValueError(f"brightness must be within 0..1, got {brightness}")
|
||||
try:
|
||||
message = _BUTTON_LED_IDS[button]
|
||||
except KeyError:
|
||||
raise ValueError(f"{button.slug} has no backlight") from None
|
||||
return _frame(message, struct.pack("<f", brightness))
|
||||
|
||||
|
||||
def encode_input_event(event: InputEvent) -> bytes:
|
||||
"""Encode an event as the firmware would send it.
|
||||
|
||||
The inverse of the decoder, used by the simulator so that simulated hardware
|
||||
exercises the real codec rather than bypassing it.
|
||||
"""
|
||||
match event:
|
||||
case RfidTokenRead(tag_id=tag_id):
|
||||
if len(tag_id) != RFID_TAG_LENGTH:
|
||||
raise ValueError(f"tag id must be {RFID_TAG_LENGTH} bytes, got {len(tag_id)}")
|
||||
message, payload = MessageFwToHost.RFID_TOKEN_READ, tag_id
|
||||
case RotaryTurned(position=position, increment=increment, direction=direction):
|
||||
message = MessageFwToHost.ROTARY_ENCODER
|
||||
payload = struct.pack("<iiB", position, increment, direction)
|
||||
case TouchButtonPressed(button=button):
|
||||
message, payload = MessageFwToHost.TOUCH_BUTTON_PRESS, struct.pack("<B", button)
|
||||
case TouchButtonReleased(button=button):
|
||||
message, payload = MessageFwToHost.TOUCH_BUTTON_RELEASE, struct.pack("<B", button)
|
||||
case ButtonEvent(button=push_button, action=action):
|
||||
message = MessageFwToHost.BUTTON_EVENT
|
||||
payload = struct.pack("<BB", push_button, action)
|
||||
case _:
|
||||
raise ValueError(f"{type(event).__name__} is not a firmware message")
|
||||
return struct.pack("<IBH", MAGIC_FW_TO_HOST, message, len(payload)) + payload
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- decoding
|
||||
|
||||
|
||||
def decode_message(msg_type: int, payload: bytes) -> InputEvent:
|
||||
"""Turn one frame payload into an event.
|
||||
|
||||
Raises:
|
||||
ProtocolError: on an unknown message type or a malformed payload.
|
||||
"""
|
||||
try:
|
||||
message = MessageFwToHost(msg_type)
|
||||
except ValueError:
|
||||
raise ProtocolError(f"unknown message type {msg_type}") from None
|
||||
|
||||
try:
|
||||
match message:
|
||||
case MessageFwToHost.RFID_TOKEN_READ:
|
||||
if len(payload) != RFID_TAG_LENGTH:
|
||||
raise ProtocolError(
|
||||
f"RFID payload must be {RFID_TAG_LENGTH} bytes, got {len(payload)}"
|
||||
)
|
||||
return RfidTokenRead(tag_id=bytes(payload), source="device")
|
||||
case MessageFwToHost.ROTARY_ENCODER:
|
||||
position, increment, direction = struct.unpack("<iiB", payload)
|
||||
return RotaryTurned(
|
||||
position=position,
|
||||
increment=increment,
|
||||
direction=RotaryDirection(direction),
|
||||
source="device",
|
||||
)
|
||||
case MessageFwToHost.TOUCH_BUTTON_PRESS:
|
||||
return TouchButtonPressed(button=_touch_button(payload), source="device")
|
||||
case MessageFwToHost.TOUCH_BUTTON_RELEASE:
|
||||
return TouchButtonReleased(button=_touch_button(payload), source="device")
|
||||
case MessageFwToHost.BUTTON_EVENT:
|
||||
button_nr, event_nr = struct.unpack("<BB", payload)
|
||||
return ButtonEvent(
|
||||
button=Button(button_nr), action=ButtonAction(event_nr), source="device"
|
||||
)
|
||||
except struct.error as exc:
|
||||
raise ProtocolError(f"malformed {message.name} payload {payload.hex()}: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise ProtocolError(f"bad value in {message.name} payload {payload.hex()}: {exc}") from exc
|
||||
|
||||
raise AssertionError(f"unhandled message {message}") # pragma: no cover
|
||||
|
||||
|
||||
def _touch_button(payload: bytes) -> TouchButton:
|
||||
if len(payload) != 1:
|
||||
raise ProtocolError(f"touch payload must be 1 byte, got {len(payload)}")
|
||||
return TouchButton(payload[0])
|
||||
|
||||
|
||||
class FrameDecoder:
|
||||
"""Incremental decoder for the byte stream coming from the firmware.
|
||||
|
||||
Handles partial frames, several frames in one chunk, and interleaved log text.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def push(self, data: bytes) -> None:
|
||||
"""Append freshly read bytes. Call :meth:`take` until it returns ``None``."""
|
||||
self._buffer += data
|
||||
|
||||
@property
|
||||
def buffered(self) -> int:
|
||||
return len(self._buffer)
|
||||
|
||||
def take(self) -> Decoded | None:
|
||||
"""Return the next complete item, or ``None`` if more bytes are needed.
|
||||
|
||||
Raises:
|
||||
ProtocolError: on a malformed frame. The offending frame has already been
|
||||
consumed, so the caller can log it and call ``take()`` again - which is
|
||||
why this is not a generator: an exception would close one for good.
|
||||
"""
|
||||
buffer = self._buffer
|
||||
if not buffer:
|
||||
return None
|
||||
|
||||
if buffer[:4] == _MAGIC_FW_BYTES:
|
||||
if len(buffer) < _HEADER_SIZE:
|
||||
return None
|
||||
_, msg_type, size = _HEADER.unpack_from(buffer)
|
||||
end = _HEADER_SIZE + size
|
||||
if len(buffer) < end:
|
||||
return None
|
||||
payload = bytes(buffer[_HEADER_SIZE:end])
|
||||
del buffer[:end]
|
||||
return decode_message(msg_type, payload)
|
||||
|
||||
if len(buffer) < 4 and _MAGIC_FW_BYTES.startswith(buffer):
|
||||
return None # could still become a frame header
|
||||
|
||||
# Not a frame at offset 0, so it is firmware log text.
|
||||
if (newline := buffer.find(b"\n")) >= 0:
|
||||
line = bytes(buffer[:newline])
|
||||
del buffer[: newline + 1]
|
||||
return FirmwareLog(line.decode("utf-8", errors="replace").rstrip("\r"))
|
||||
|
||||
# No newline yet: skip ahead to the next frame if one has already started.
|
||||
if (start := buffer.find(_MAGIC_FW_BYTES)) > 0:
|
||||
skipped = bytes(buffer[:start])
|
||||
del buffer[:start]
|
||||
return FirmwareLog(skipped.decode("utf-8", errors="replace").rstrip("\r"))
|
||||
|
||||
if len(buffer) > _MAX_BUFFER:
|
||||
# Nothing recognisable and no end in sight - keep only what could still be
|
||||
# the beginning of a magic token straddling the next chunk.
|
||||
del buffer[: -len(_MAGIC_FW_BYTES) + 1]
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------- host -> firmware, read back
|
||||
#
|
||||
# Nothing in the running app needs this direction decoded - the firmware does that.
|
||||
# The simulator uses it to report what the real device would have been told, which
|
||||
# also means simulated hardware exercises the encoders rather than bypassing them.
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetEffect:
|
||||
zone: LedZone
|
||||
effect: LedEffect
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.zone} <- {self.effect}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetButtonBrightness:
|
||||
button: Button
|
||||
brightness: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
||||
|
||||
|
||||
HostCommand: TypeAlias = SetEffect | SetButtonBrightness
|
||||
|
||||
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
||||
message: (zone, effect_cls)
|
||||
for zone, effects in _EFFECT_IDS.items()
|
||||
for effect_cls, message in effects.items()
|
||||
}
|
||||
_ID_TO_BUTTON: dict[int, Button] = {
|
||||
message: button for button, message in _BUTTON_LED_IDS.items()
|
||||
}
|
||||
_MAGIC_HOST_BYTES = struct.pack("<I", MAGIC_HOST_TO_FW)
|
||||
|
||||
|
||||
def decode_host_command(msg_type: int, payload: bytes) -> HostCommand:
|
||||
if (target := _ID_TO_EFFECT.get(msg_type)) is not None:
|
||||
zone, effect_cls = target
|
||||
try:
|
||||
return SetEffect(zone=zone, effect=effect_cls.from_bytes(payload))
|
||||
except (ValueError, struct.error) as exc:
|
||||
raise ProtocolError(f"malformed {effect_cls.__name__} payload: {exc}") from exc
|
||||
if (button := _ID_TO_BUTTON.get(msg_type)) is not None:
|
||||
try:
|
||||
(brightness,) = struct.unpack("<f", payload)
|
||||
except struct.error as exc:
|
||||
raise ProtocolError(f"malformed button brightness payload: {exc}") from exc
|
||||
return SetButtonBrightness(button=button, brightness=brightness)
|
||||
raise ProtocolError(f"unknown host-to-firmware message type {msg_type}")
|
||||
|
||||
|
||||
class HostFrameDecoder:
|
||||
"""Incremental decoder for the host -> firmware direction."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def push(self, data: bytes) -> None:
|
||||
self._buffer += data
|
||||
|
||||
def take(self) -> HostCommand | None:
|
||||
"""Next command, or ``None`` if more bytes are needed. See :meth:`FrameDecoder.take`."""
|
||||
if len(self._buffer) < _HEADER_SIZE:
|
||||
return None
|
||||
magic, msg_type, size = _HEADER.unpack_from(self._buffer)
|
||||
if magic != MAGIC_HOST_TO_FW:
|
||||
self._buffer.clear()
|
||||
raise ProtocolError(f"expected host-to-firmware magic, got {magic:#010x}")
|
||||
end = _HEADER_SIZE + size
|
||||
if len(self._buffer) < end:
|
||||
return None
|
||||
payload = bytes(self._buffer[_HEADER_SIZE:end])
|
||||
del self._buffer[:end]
|
||||
return decode_host_command(msg_type, payload)
|
||||
367
python-backend/musicmouse/effects.py
Normal file
367
python-backend/musicmouse/effects.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""LED effect configurations and their firmware wire encoding.
|
||||
|
||||
Each dataclass mirrors a ``struct`` in ``esp-firmware/lib/ledtl/effects/`` and its
|
||||
``as_bytes()`` is the byte-for-byte payload the firmware expects. ``tests/test_effects.py``
|
||||
pins those layouts.
|
||||
|
||||
``from_bytes()`` is the inverse. Nothing in the running app decodes effects - the
|
||||
firmware does that - but the simulator uses it to show what the real device would have
|
||||
been told, which also keeps the encoders honest.
|
||||
|
||||
Formerly ``led_cmds.py``. ``EffectReverseSwipe``'s fields were camelCase there, copied
|
||||
from the C++ side; they are snake_case here like every other effect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar, Protocol, Self
|
||||
|
||||
from musicmouse.color import ColorHSV, ColorRGBW
|
||||
|
||||
__all__ = [
|
||||
"OFF",
|
||||
"SEGMENT_SELECT_MAX_SEGMENTS",
|
||||
"EffectAlexaSwipeConfig",
|
||||
"EffectCircularConfig",
|
||||
"EffectRandomTwoColorInterpolationConfig",
|
||||
"EffectReverseSwipe",
|
||||
"EffectSegmentSelect",
|
||||
"EffectStaticConfig",
|
||||
"EffectStaticDetailedConfig",
|
||||
"EffectSwipeAndChange",
|
||||
"LedEffect",
|
||||
]
|
||||
|
||||
_RGBW_SIZE = 4
|
||||
_HSV_SIZE = 12
|
||||
|
||||
_STATIC = struct.Struct("<HH")
|
||||
_STATIC_DETAILED = struct.Struct("<Hfff")
|
||||
_ALEXA_SWIPE = struct.Struct("<fffff?")
|
||||
_TWO_COLOR = struct.Struct("<i?i??")
|
||||
_CIRCULAR = struct.Struct("<ff")
|
||||
_REVERSE_SWIPE = struct.Struct("<fff")
|
||||
_SEGMENT_SELECT_HEAD = struct.Struct("<BBBB")
|
||||
_SEGMENT_SELECT_TAIL = struct.Struct("<fbfffff")
|
||||
|
||||
#: ``SEGMENT_SELECT_MAX_SEGMENTS`` in ``SegmentSelect.h``: the colour array is this long
|
||||
#: on the wire whatever ``count`` is.
|
||||
SEGMENT_SELECT_MAX_SEGMENTS = 6
|
||||
|
||||
|
||||
class LedEffect(Protocol):
|
||||
"""Anything that can be sent to an LED zone, and read back off the wire."""
|
||||
|
||||
SIZE: ClassVar[int]
|
||||
|
||||
def as_bytes(self) -> bytes: ...
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self: ...
|
||||
|
||||
|
||||
def _check_size(data: bytes, expected: int, name: str) -> None:
|
||||
if len(data) != expected:
|
||||
raise ValueError(f"{name} payload must be {expected} bytes, got {len(data)}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticConfig:
|
||||
color: ColorRGBW
|
||||
begin: int = 0
|
||||
end: int = 0
|
||||
|
||||
SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC.size
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + _STATIC.pack(self.begin, self.end)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectStaticConfig:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
begin, end = _STATIC.unpack(data[_RGBW_SIZE:])
|
||||
return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), begin, end)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Static({self.color}, begin={self.begin}, end={self.end})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticDetailedConfig:
|
||||
color: ColorRGBW
|
||||
increment: int = 1
|
||||
begin: float = 0.0
|
||||
end: float = 1.0
|
||||
transition_time_in_ms: float = 500
|
||||
|
||||
SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC_DETAILED.size
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + _STATIC_DETAILED.pack(
|
||||
self.increment, self.begin, self.end, self.transition_time_in_ms
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectStaticDetailedConfig:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
increment, begin, end, transition = _STATIC_DETAILED.unpack(data[_RGBW_SIZE:])
|
||||
return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), increment, begin, end, transition)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"StaticDetailed({self.color}, begin={self.begin}, end={self.end}, "
|
||||
f"increment={self.increment}, transition={self.transition_time_in_ms}ms)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectAlexaSwipeConfig:
|
||||
primary_color_width: float = 20 # degrees
|
||||
transition_width: float = 30 # degrees
|
||||
swipe_speed: float = 2 * 360 # degrees per second
|
||||
bell_curve_width_in_leds: float = 3
|
||||
start_position: float = 180 # degrees
|
||||
forward: bool = True
|
||||
primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
|
||||
secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0))
|
||||
|
||||
SIZE: ClassVar[int] = _ALEXA_SWIPE.size + 2 * _RGBW_SIZE
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return (
|
||||
_ALEXA_SWIPE.pack(
|
||||
self.primary_color_width,
|
||||
self.transition_width,
|
||||
self.swipe_speed,
|
||||
self.bell_curve_width_in_leds,
|
||||
self.start_position,
|
||||
self.forward,
|
||||
)
|
||||
+ self.primary_color.as_bytes()
|
||||
+ self.secondary_color.as_bytes()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectAlexaSwipeConfig:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
primary_width, transition, speed, bell_width, start, forward = _ALEXA_SWIPE.unpack_from(
|
||||
data
|
||||
)
|
||||
colors = data[_ALEXA_SWIPE.size :]
|
||||
return cls(
|
||||
primary_color_width=primary_width,
|
||||
transition_width=transition,
|
||||
swipe_speed=speed,
|
||||
bell_curve_width_in_leds=bell_width,
|
||||
start_position=start,
|
||||
forward=forward,
|
||||
primary_color=ColorRGBW.from_bytes(colors[:_RGBW_SIZE]),
|
||||
secondary_color=ColorRGBW.from_bytes(colors[_RGBW_SIZE:]),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AlexaSwipe({self.primary_color} -> {self.secondary_color})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectRandomTwoColorInterpolationConfig:
|
||||
cycle_durations_ms: int = 6000
|
||||
start_with_existing: bool = True
|
||||
num_segments: int = 3
|
||||
hue1_random: bool = False
|
||||
hue2_random: bool = False
|
||||
color1: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(240, 1, 1))
|
||||
color2: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(192, 1, 1))
|
||||
|
||||
SIZE: ClassVar[int] = _TWO_COLOR.size + 2 * _HSV_SIZE
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
c1 = ColorHSV.from_rgb(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
|
||||
c2 = ColorHSV.from_rgb(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
|
||||
return (
|
||||
_TWO_COLOR.pack(
|
||||
self.cycle_durations_ms,
|
||||
self.start_with_existing,
|
||||
self.num_segments,
|
||||
self.hue1_random,
|
||||
self.hue2_random,
|
||||
)
|
||||
+ c1.as_bytes()
|
||||
+ c2.as_bytes()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectRandomTwoColorInterpolationConfig:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
cycle, start_with_existing, segments, hue1, hue2 = _TWO_COLOR.unpack_from(data)
|
||||
colors = data[_TWO_COLOR.size :]
|
||||
return cls(
|
||||
cycle_durations_ms=cycle,
|
||||
start_with_existing=start_with_existing,
|
||||
num_segments=segments,
|
||||
hue1_random=hue1,
|
||||
hue2_random=hue2,
|
||||
color1=ColorHSV.from_bytes(colors[:_HSV_SIZE]),
|
||||
color2=ColorHSV.from_bytes(colors[_HSV_SIZE:]),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TwoColor({self.color1}, {self.color2}, segments={self.num_segments})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCircularConfig:
|
||||
speed: float = 360 # degrees per second
|
||||
width: float = 180 # degrees
|
||||
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
|
||||
|
||||
SIZE: ClassVar[int] = _CIRCULAR.size + _RGBW_SIZE
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return _CIRCULAR.pack(self.speed, self.width) + self.color.as_bytes()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectCircularConfig:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
speed, width = _CIRCULAR.unpack_from(data)
|
||||
return cls(speed, width, ColorRGBW.from_bytes(data[_CIRCULAR.size :]))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Circular({self.color}, speed={self.speed}, width={self.width})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectSwipeAndChange:
|
||||
swipe: EffectAlexaSwipeConfig = field(default_factory=EffectAlexaSwipeConfig)
|
||||
change: EffectRandomTwoColorInterpolationConfig = field(
|
||||
default_factory=EffectRandomTwoColorInterpolationConfig
|
||||
)
|
||||
|
||||
SIZE: ClassVar[int] = EffectAlexaSwipeConfig.SIZE + EffectRandomTwoColorInterpolationConfig.SIZE
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.swipe.as_bytes() + self.change.as_bytes()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectSwipeAndChange:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
split = EffectAlexaSwipeConfig.SIZE
|
||||
return cls(
|
||||
EffectAlexaSwipeConfig.from_bytes(data[:split]),
|
||||
EffectRandomTwoColorInterpolationConfig.from_bytes(data[split:]),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SwipeAndChange({self.swipe}, {self.change})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectReverseSwipe:
|
||||
swipe_speed: float = 2 * 360
|
||||
bell_curve_width_in_leds: float = 3
|
||||
start_position: float = 180
|
||||
|
||||
SIZE: ClassVar[int] = _REVERSE_SWIPE.size
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return _REVERSE_SWIPE.pack(
|
||||
self.swipe_speed, self.bell_curve_width_in_leds, self.start_position
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectReverseSwipe:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
return cls(*_REVERSE_SWIPE.unpack(data))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ReverseSwipe(speed={self.swipe_speed}, "
|
||||
f"width={self.bell_curve_width_in_leds}, start={self.start_position})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectSegmentSelect:
|
||||
"""The ring as a picker: one section per choice, the selected one bright.
|
||||
|
||||
Re-sent on every rotary detent. ``previous`` is where the highlight glides from, so
|
||||
the fresh effect the firmware builds per message still moves smoothly.
|
||||
"""
|
||||
|
||||
colors: tuple[ColorRGBW, ...]
|
||||
selected: int = 0
|
||||
previous: int | None = None # None: same as selected, no glide
|
||||
gap_leds: int = 1
|
||||
offset_degrees: float = 180
|
||||
direction: int = 1
|
||||
dim_factor: float = 0.15
|
||||
wobble_amplitude: float = 1.5 # LEDs
|
||||
wobble_period_ms: float = 1000
|
||||
slide_ms: float = 200
|
||||
edge_blend: float = 0.25
|
||||
|
||||
SIZE: ClassVar[int] = (
|
||||
_SEGMENT_SELECT_HEAD.size
|
||||
+ SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||
+ _SEGMENT_SELECT_TAIL.size
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 1 <= len(self.colors) <= SEGMENT_SELECT_MAX_SEGMENTS:
|
||||
raise ValueError(
|
||||
f"between 1 and {SEGMENT_SELECT_MAX_SEGMENTS} sections, got {len(self.colors)}"
|
||||
)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
count = len(self.colors)
|
||||
previous = self.selected if self.previous is None else self.previous
|
||||
padding = (ColorRGBW(0, 0, 0, 0),) * (SEGMENT_SELECT_MAX_SEGMENTS - count)
|
||||
return (
|
||||
_SEGMENT_SELECT_HEAD.pack(count, self.selected, previous, self.gap_leds)
|
||||
+ b"".join(color.as_bytes() for color in (*self.colors, *padding))
|
||||
+ _SEGMENT_SELECT_TAIL.pack(
|
||||
self.offset_degrees,
|
||||
self.direction,
|
||||
self.dim_factor,
|
||||
self.wobble_amplitude,
|
||||
self.wobble_period_ms,
|
||||
self.slide_ms,
|
||||
self.edge_blend,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> EffectSegmentSelect:
|
||||
_check_size(data, cls.SIZE, cls.__name__)
|
||||
count, selected, previous, gap = _SEGMENT_SELECT_HEAD.unpack_from(data)
|
||||
start = _SEGMENT_SELECT_HEAD.size
|
||||
colors = tuple(
|
||||
ColorRGBW.from_bytes(data[start + i * _RGBW_SIZE : start + (i + 1) * _RGBW_SIZE])
|
||||
for i in range(count)
|
||||
)
|
||||
offset, direction, dim, amplitude, period, slide, blend = _SEGMENT_SELECT_TAIL.unpack_from(
|
||||
data, start + SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||
)
|
||||
return cls(
|
||||
colors=colors,
|
||||
selected=selected,
|
||||
previous=None if previous == selected else previous,
|
||||
gap_leds=gap,
|
||||
offset_degrees=offset,
|
||||
direction=direction,
|
||||
dim_factor=dim,
|
||||
wobble_amplitude=amplitude,
|
||||
wobble_period_ms=period,
|
||||
slide_ms=slide,
|
||||
edge_blend=blend,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SegmentSelect({self.previous} -> {self.selected} of {len(self.colors)})"
|
||||
|
||||
|
||||
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
|
||||
"""A fresh "all LEDs off" effect."""
|
||||
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
356
python-backend/musicmouse/events.py
Normal file
356
python-backend/musicmouse/events.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""The event vocabulary.
|
||||
|
||||
Three flavours, distinguished by base class:
|
||||
|
||||
* :class:`InputEvent` - something happened (hardware, player).
|
||||
* :class:`IntentEvent` - something was requested (button, MQTT, web, simulator).
|
||||
* :class:`StateEvent` - something changed.
|
||||
|
||||
The intent layer is what lets several front-ends drive the same behaviour: a button
|
||||
press, an MQTT command, a web request and the IR remote all emit ``NextTrackRequested``
|
||||
and a single reaction acts on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias
|
||||
|
||||
from musicmouse.effects import LedEffect
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||
from musicmouse.media import Playlist, Track
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmouse.config import Digit
|
||||
|
||||
__all__ = [
|
||||
"ActiveFigureChanged",
|
||||
"ButtonEvent",
|
||||
"CancelTimerRequested",
|
||||
"ClockStateChanged",
|
||||
"ConnectionChanged",
|
||||
"Event",
|
||||
"EventSource",
|
||||
"FigureSelectionChanged",
|
||||
"FigureSelectorExpired",
|
||||
"InputEvent",
|
||||
"IntentEvent",
|
||||
"LedEffectChanged",
|
||||
"LedEffectRequested",
|
||||
"NextTrackRequested",
|
||||
"PauseRequested",
|
||||
"PlayAlbumRequested",
|
||||
"PlayFigureRequested",
|
||||
"PlayRemoteSlotRequested",
|
||||
"PlayRequested",
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
"PlaylistFinished",
|
||||
"PreviousTrackRequested",
|
||||
"RfidTokenRead",
|
||||
"RotaryTurned",
|
||||
"SelectorPressed",
|
||||
"SelectorTurned",
|
||||
"SetTimerRequested",
|
||||
"SetVolumeRequested",
|
||||
"StateEvent",
|
||||
"ToggleClockRequested",
|
||||
"ToggleScreenRequested",
|
||||
"TouchButtonPressed",
|
||||
"TouchButtonReleased",
|
||||
"TrackChanged",
|
||||
"UserActivity",
|
||||
"VolumeChangeRequested",
|
||||
"VolumeChanged",
|
||||
]
|
||||
|
||||
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Event:
|
||||
"""Base for every event. Keyword-only so subclasses can add required fields."""
|
||||
|
||||
source: EventSource = "system"
|
||||
timestamp: float = field(default_factory=time.monotonic, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputEvent(Event):
|
||||
"""Something happened out in the world."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class IntentEvent(Event):
|
||||
"""Something was requested. May come from any front-end."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class StateEvent(Event):
|
||||
"""Something changed. Front-ends mirror these outwards."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- input
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RfidTokenRead(InputEvent):
|
||||
"""A tag was read. ``figure`` is ``None`` for the all-zero "removed" tag and for
|
||||
tags that match no configured figure (``known`` tells the two apart)."""
|
||||
|
||||
tag_id: bytes
|
||||
figure: str | None = None
|
||||
known: bool = True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
tag = self.tag_id.hex()
|
||||
return f"RfidTokenRead({tag}, figure={self.figure!r})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ButtonEvent(InputEvent):
|
||||
button: Button
|
||||
action: ButtonAction
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ButtonEvent({self.button.slug}, {self.action.slug})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TouchButtonPressed(InputEvent):
|
||||
button: TouchButton
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TouchButtonPressed({self.button.slug})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TouchButtonReleased(InputEvent):
|
||||
button: TouchButton
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TouchButtonReleased({self.button.slug})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RotaryTurned(InputEvent):
|
||||
position: int
|
||||
increment: int
|
||||
direction: RotaryDirection
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SelectorTurned(InputEvent):
|
||||
"""The rotary encoder turned while the ring's album picker had it.
|
||||
|
||||
Not a :class:`RotaryTurned`: while the picker is open the knob is not a volume
|
||||
knob, and nothing that listens for one should hear it.
|
||||
"""
|
||||
|
||||
#: Detents, positive for the direction that turns the volume up.
|
||||
steps: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SelectorPressed(InputEvent):
|
||||
"""The rotary encoder was pressed while the ring's album picker had it. Instead of,
|
||||
not as well as, a :class:`ButtonEvent` - so it is not published to Home Assistant."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FigureSelectorExpired(InputEvent):
|
||||
"""The ring's album picker went untouched for long enough to close by itself."""
|
||||
|
||||
figure: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaylistFinished(InputEvent):
|
||||
"""The player reached the end of the playlist."""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- intents
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlayRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PauseRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class NextTrackRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PreviousTrackRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlayFigureRequested(IntentEvent):
|
||||
"""Start a figure's playlist. ``restart=False`` resumes where it left off."""
|
||||
|
||||
figure: str
|
||||
restart: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlayAlbumRequested(IntentEvent):
|
||||
"""Start any album from the library, figure or not."""
|
||||
|
||||
album_id: str
|
||||
track_index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaySeriesLatestRequested(IntentEvent):
|
||||
"""Start the newest episode of a podcast show.
|
||||
|
||||
The IR remote's number-key mapping assigns a whole show rather than one fixed
|
||||
episode, so this is resolved to an actual album fresh on every press.
|
||||
"""
|
||||
|
||||
series: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlayRemoteSlotRequested(IntentEvent):
|
||||
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
|
||||
|
||||
The IR remote's digits emit this, and so do the number keys of the web front-end's
|
||||
keyboard - one reaction resolves the slot for both.
|
||||
"""
|
||||
|
||||
digit: Digit
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SeekRequested(IntentEvent):
|
||||
#: Seconds from the start of the current track.
|
||||
position: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class VolumeChangeRequested(IntentEvent):
|
||||
delta: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SetVolumeRequested(IntentEvent):
|
||||
volume: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ToggleScreenRequested(IntentEvent):
|
||||
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ToggleClockRequested(IntentEvent):
|
||||
"""Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a
|
||||
front-end thing, the backend just passes the remote's key along."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SetTimerRequested(IntentEvent):
|
||||
"""Start the parents' countdown, replacing one already running."""
|
||||
|
||||
#: Wall-clock end, as Unix seconds.
|
||||
ends_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CancelTimerRequested(IntentEvent):
|
||||
"""Stop the parents' countdown, or silence it if it is ringing."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class UserActivity(IntentEvent):
|
||||
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
|
||||
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class LedEffectRequested(IntentEvent):
|
||||
zone: LedZone
|
||||
effect: LedEffect
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LedEffectRequested({self.zone}, {self.effect}, from={self.source})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- state
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaybackChanged(StateEvent):
|
||||
playing: bool
|
||||
figure: str | None = None
|
||||
playlist: Playlist | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TrackChanged(StateEvent):
|
||||
index: int
|
||||
track: Track | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class VolumeChanged(StateEvent):
|
||||
volume: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ActiveFigureChanged(StateEvent):
|
||||
figure: str | None
|
||||
previous: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FigureSelectionChanged(StateEvent):
|
||||
"""The ring's album picker opened, moved or closed."""
|
||||
|
||||
figure: str
|
||||
#: Index into the figure's albums of the highlighted one.
|
||||
selected: int
|
||||
count: int
|
||||
open: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class LedEffectChanged(StateEvent):
|
||||
"""Emitted on *every* write to an LED zone, whatever caused it.
|
||||
|
||||
Front-ends publish zone state from this rather than echoing their own commands,
|
||||
so Home Assistant keeps showing the strip's real state when a figure animation
|
||||
overrides an MQTT-set colour.
|
||||
"""
|
||||
|
||||
zone: LedZone
|
||||
effect: LedEffect
|
||||
origin: EventSource
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ClockStateChanged(StateEvent):
|
||||
"""The parents' timer started, rang or stopped, or today's school day changed.
|
||||
The values themselves live on :class:`~musicmouse.app.AppState`."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ConnectionChanged(StateEvent):
|
||||
target: Literal["firmware", "mqtt", "lirc"]
|
||||
connected: bool
|
||||
90
python-backend/musicmouse/hardware.py
Normal file
90
python-backend/musicmouse/hardware.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Hardware vocabulary: the enums and geometry the firmware and the host agree on.
|
||||
|
||||
The integer values of :class:`Button`, :class:`ButtonAction`, :class:`TouchButton` and
|
||||
:class:`RotaryDirection` are wire values and must match ``esp-firmware/src/Messages.h``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum, StrEnum
|
||||
|
||||
__all__ = [
|
||||
"MOUSE_LED_RANGES",
|
||||
"NO_FIGURE_TAG",
|
||||
"RFID_TAG_LENGTH",
|
||||
"Button",
|
||||
"ButtonAction",
|
||||
"LedZone",
|
||||
"RotaryDirection",
|
||||
"TouchButton",
|
||||
]
|
||||
|
||||
#: Length of an RFID tag id in bytes (``uint8_t tagId[5]`` in ``Messages.h``).
|
||||
RFID_TAG_LENGTH = 5
|
||||
|
||||
#: The all-zero tag the firmware reports when nothing is on the reader.
|
||||
NO_FIGURE_TAG = bytes(RFID_TAG_LENGTH)
|
||||
|
||||
|
||||
class Button(IntEnum):
|
||||
"""The three physical push buttons."""
|
||||
|
||||
LEFT = 1
|
||||
RIGHT = 2
|
||||
ROTARY = 3
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
|
||||
class ButtonAction(IntEnum):
|
||||
"""AceButton event types, as reported by the firmware."""
|
||||
|
||||
PRESSED = 0
|
||||
RELEASED = 1
|
||||
CLICKED = 2
|
||||
DOUBLE_CLICKED = 3
|
||||
LONG_PRESSED = 4
|
||||
REPEAT_PRESSED = 5
|
||||
LONG_RELEASED = 6
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
|
||||
class TouchButton(IntEnum):
|
||||
"""The four capacitive touch areas on the mouse body."""
|
||||
|
||||
LEFT_FOOT = 0
|
||||
RIGHT_FOOT = 1
|
||||
LEFT_EAR = 2
|
||||
RIGHT_EAR = 3
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
|
||||
class RotaryDirection(IntEnum):
|
||||
NONE = 0
|
||||
DOWN = 1
|
||||
UP = 2
|
||||
|
||||
|
||||
class LedZone(StrEnum):
|
||||
"""The three independently addressable LED strips."""
|
||||
|
||||
RING = "ring"
|
||||
MOUSE = "mouse"
|
||||
SHELF = "shelf"
|
||||
|
||||
|
||||
#: LED index span (begin, end) lit up when a given touch area is touched.
|
||||
MOUSE_LED_RANGES: dict[TouchButton, tuple[int, int]] = {
|
||||
TouchButton.RIGHT_FOOT: (0, 6),
|
||||
TouchButton.LEFT_FOOT: (6, 12),
|
||||
TouchButton.LEFT_EAR: (12, 28),
|
||||
TouchButton.RIGHT_EAR: (28, 45),
|
||||
}
|
||||
437
python-backend/musicmouse/library/__init__.py
Normal file
437
python-backend/musicmouse/library/__init__.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""The music collection: what exists, what it is called, and what colour it is.
|
||||
|
||||
The scan is the only part of this backend that touches hundreds of files, so it runs
|
||||
in a worker thread and its results are cached. Everything the rest of the app sees is
|
||||
plain immutable data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable, Collection, Mapping
|
||||
from concurrent.futures import BrokenExecutor, Executor
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from musicmouse.library.analysis import (
|
||||
ANALYZER_VERSION,
|
||||
Analyzer,
|
||||
BeatGrid,
|
||||
NullAnalyzer,
|
||||
TrackAnalysis,
|
||||
TrackCurves,
|
||||
)
|
||||
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
||||
from musicmouse.library.scanner import scan_library
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
|
||||
from musicmouse.media import Playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ANALYZER_VERSION",
|
||||
"MAX_FIGURE_ALBUMS",
|
||||
"SECTIONS",
|
||||
"Album",
|
||||
"Analyzer",
|
||||
"BeatGrid",
|
||||
"LibraryCache",
|
||||
"LibraryTrack",
|
||||
"MusicLibrary",
|
||||
"NullAnalyzer",
|
||||
"TrackCurves",
|
||||
"album_id",
|
||||
"default_worker_count",
|
||||
"track_key",
|
||||
]
|
||||
|
||||
#: How many albums one figure can offer. Each is a section of the LED ring, and past six
|
||||
#: they get too narrow to tell apart; the firmware's colour array is no longer either.
|
||||
MAX_FIGURE_ALBUMS: Final = 6
|
||||
|
||||
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
||||
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
||||
#: of them in a typical library than there are songs.
|
||||
_ANALYZED_KINDS: Final[tuple[AlbumKind, ...]] = ("music",)
|
||||
|
||||
#: How often the background worker checks whether it may resume after `is_busy()` said
|
||||
#: no. A track's own analysis is a couple of seconds of CPU, so overshooting by this
|
||||
#: much when playback stops is not worth polling harder for.
|
||||
_BUSY_POLL_SECONDS: Final = 2.0
|
||||
|
||||
#: Analysis is folded back into the live index and persisted this often during a long
|
||||
#: run, so a first-time pass over an unanalyzed library shows up gradually in open
|
||||
#: browser tabs rather than only after the whole thing finishes.
|
||||
_PUBLISH_BATCH_SIZE: Final = 25
|
||||
|
||||
#: How often a long analysis pass reports where it is, so a first-time run over a large
|
||||
#: library - minutes of DSP per track - doesn't sit silent with nothing on the console
|
||||
#: to say it is still going.
|
||||
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
||||
|
||||
|
||||
def _discard(future: asyncio.Future[Any]) -> None:
|
||||
"""Drop a result nobody is going to read, without leaving a warning behind."""
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
elif not future.cancelled():
|
||||
future.exception()
|
||||
|
||||
|
||||
class MusicLibrary:
|
||||
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
cache: LibraryCache,
|
||||
extensions: frozenset[str],
|
||||
*,
|
||||
analyzer: Analyzer | None = None,
|
||||
analysis_workers: int = 1,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.cache = cache
|
||||
self.extensions = extensions
|
||||
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
||||
#: How many tracks background analysis may work on at once. The default of 1
|
||||
#: keeps the analyzer in this process, where an analyzer that holds state
|
||||
#: still behaves as written; the app passes `default_worker_count()`, which is
|
||||
#: what makes a first-time pass finish in hours rather than days on a desktop.
|
||||
#: See `musicmouse.library.workers`.
|
||||
self.analysis_workers = analysis_workers
|
||||
#: What each figure holds. The only thing a folder name cannot say.
|
||||
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
||||
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
#: Set by `request_analysis`, consumed by `run_analysis`. An `Event` rather than
|
||||
#: a queue: a request raised while one is already pending or running just
|
||||
#: coalesces into it, which is exactly what "look again" should mean here.
|
||||
self._analysis_requested = asyncio.Event()
|
||||
|
||||
# -------------------------------------------------------------------- reading
|
||||
|
||||
@property
|
||||
def albums(self) -> list[Album]:
|
||||
return [album for album, _ in self._entries.values()]
|
||||
|
||||
def get(self, identifier: str | None) -> Album | None:
|
||||
entry = self._entries.get(identifier) if identifier else None
|
||||
return entry[0] if entry else None
|
||||
|
||||
def figure_playlists(self) -> dict[str, tuple[Playlist, ...]]:
|
||||
"""The albums a figure can play, keyed by figure name, in folder order.
|
||||
|
||||
Usually one. A figure whose folder holds album subfolders rather than audio has
|
||||
one per subfolder, depth-first and alphabetical, which is the order the LED ring
|
||||
offers them in - and the ring has room for no more than
|
||||
:data:`MAX_FIGURE_ALBUMS`.
|
||||
"""
|
||||
by_figure: dict[str, list[Album]] = {}
|
||||
for album, _ in self._entries.values():
|
||||
if album.figure is not None:
|
||||
by_figure.setdefault(album.figure, []).append(album)
|
||||
|
||||
out: dict[str, tuple[Playlist, ...]] = {}
|
||||
for figure, albums in by_figure.items():
|
||||
albums.sort(key=lambda album: album.folder.parts)
|
||||
if len(albums) > MAX_FIGURE_ALBUMS:
|
||||
_log.warning(
|
||||
"Figure %r has %d albums; only the first %d can be picked on the ring: %s",
|
||||
figure,
|
||||
len(albums),
|
||||
MAX_FIGURE_ALBUMS,
|
||||
", ".join(album.folder.name for album in albums[:MAX_FIGURE_ALBUMS]),
|
||||
)
|
||||
out[figure] = tuple(album.to_playlist() for album in albums[:MAX_FIGURE_ALBUMS])
|
||||
return out
|
||||
|
||||
def latest_episode(self, series: str) -> Album | None:
|
||||
"""The newest episode-unit album of a podcast show, by filename.
|
||||
|
||||
Episode files are named ``YYYYMMDD - Title``, so filename order is
|
||||
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
|
||||
already relies on at scan time. ``None`` if the show is unknown or empty.
|
||||
"""
|
||||
candidates = [
|
||||
album
|
||||
for album in self.albums
|
||||
if album.series == series
|
||||
and (section := SECTIONS.get(album.section)) is not None
|
||||
and section.album_unit == "episode"
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
|
||||
|
||||
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||
album = self.get(identifier)
|
||||
if album is None or not 0 <= index < len(album.tracks):
|
||||
return None
|
||||
return self.cache.load_beats(track_key(album.tracks[index].path))
|
||||
|
||||
def curve(self, identifier: str, index: int) -> TrackCurves | None:
|
||||
album = self.get(identifier)
|
||||
if album is None or not 0 <= index < len(album.tracks):
|
||||
return None
|
||||
return self.cache.load_curve(track_key(album.tracks[index].path))
|
||||
|
||||
# -------------------------------------------------------------------- writing
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic.
|
||||
|
||||
Ends by waking the background analyzer: a rescan is exactly when new tracks -
|
||||
the only ones analysis can be pending for - enter the index, whether that is
|
||||
the startup scan or a parent tapping "Bibliothek neu einlesen".
|
||||
"""
|
||||
known = dict(self._entries)
|
||||
entries = await asyncio.to_thread(
|
||||
scan_library,
|
||||
self.root,
|
||||
self.extensions,
|
||||
self.cache,
|
||||
known=known,
|
||||
figure_kinds=self.figure_kinds,
|
||||
)
|
||||
self._entries = await asyncio.to_thread(self._with_analysis, entries)
|
||||
await asyncio.to_thread(self.cache.store_index, self._entries)
|
||||
self.request_analysis()
|
||||
|
||||
def _with_analysis(
|
||||
self,
|
||||
entries: dict[str, tuple[Album, Fingerprint]],
|
||||
*,
|
||||
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""Fold cached analysis results into the freshly scanned index.
|
||||
|
||||
Restricted to `kinds` (music by default): stat-ing hundreds of book and podcast
|
||||
tracks whose analysis can never exist would be pure waste. Skipped entirely
|
||||
while ``analysis/`` is empty, which is the normal case until an analyzer has
|
||||
actually been run.
|
||||
"""
|
||||
if not any(self.cache.analysis.glob("*.json")):
|
||||
return entries
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for identifier, (album, fingerprint) in entries.items():
|
||||
if album.kind not in kinds:
|
||||
out[identifier] = (album, fingerprint)
|
||||
continue
|
||||
tracks = tuple(
|
||||
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
|
||||
for track in album.tracks
|
||||
)
|
||||
out[identifier] = (replace(album, tracks=tracks), fingerprint)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def build(
|
||||
cls,
|
||||
root: Path,
|
||||
cache_dir: Path,
|
||||
extensions: frozenset[str],
|
||||
*,
|
||||
analyzer: Analyzer | None = None,
|
||||
analysis_workers: int = 1,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> MusicLibrary:
|
||||
cache = LibraryCache(cache_dir)
|
||||
library = cls(
|
||||
root,
|
||||
cache,
|
||||
extensions,
|
||||
analyzer=analyzer,
|
||||
analysis_workers=analysis_workers,
|
||||
figure_kinds=figure_kinds,
|
||||
)
|
||||
library._entries = await asyncio.to_thread(cache.load_index)
|
||||
await library.refresh()
|
||||
return library
|
||||
|
||||
# ------------------------------------------------------------------- analysis
|
||||
|
||||
def request_analysis(self) -> None:
|
||||
"""Wake the background worker to look for tracks with no current analysis.
|
||||
|
||||
Idempotent, and safe to call before `run_analysis` has even started a first
|
||||
time - the request just waits on the event.
|
||||
"""
|
||||
self._analysis_requested.set()
|
||||
|
||||
async def run_analysis(
|
||||
self,
|
||||
*,
|
||||
is_busy: Callable[[], bool] = lambda: False,
|
||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
"""Analyze pending tracks whenever `request_analysis` wakes this up.
|
||||
|
||||
A long-running task, cancelled at shutdown alongside every other one. Loops
|
||||
forever so a request raised *during* a pass (a rescan mid-analysis) starts
|
||||
another pass right after, rather than being lost.
|
||||
"""
|
||||
while True:
|
||||
await self._analysis_requested.wait()
|
||||
self._analysis_requested.clear()
|
||||
await self.analyze_pending(is_busy=is_busy, on_batch=on_batch)
|
||||
|
||||
async def analyze_pending(
|
||||
self,
|
||||
*,
|
||||
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
|
||||
is_busy: Callable[[], bool] = lambda: False,
|
||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||
batch_size: int = _PUBLISH_BATCH_SIZE,
|
||||
workers: int | None = None,
|
||||
) -> int:
|
||||
"""Run the analyzer over tracks of `kinds` that have no current result.
|
||||
|
||||
Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
|
||||
are analyzed at once (`self.analysis_workers` when not given), each in its own
|
||||
worker process - see `musicmouse.library.workers` for why processes and how the
|
||||
machine is kept usable while they run. Checked before every track is handed out,
|
||||
`is_busy()` pauses the whole pass rather than one file: analysis must never
|
||||
compete with audio decoding for CPU, and a children's player is idle most of the
|
||||
day, so the pass simply resumes next time it is. Tracks already in flight when
|
||||
it goes busy are allowed to finish - their results are already paid for - and
|
||||
the workers are then shut down for the duration rather than sitting idle with
|
||||
a librosa apiece resident on a machine that is now playing music.
|
||||
|
||||
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
|
||||
as attempted - with every scalar left `None` - so it is never retried forever and
|
||||
the frontend falls back to the un-analyzed baseline for it. A worker *process*
|
||||
dying, though, says nothing about the track it was on, so that ends the pass
|
||||
without recording anything: the next one picks the same tracks up again.
|
||||
|
||||
Results are folded into the live index and persisted every `batch_size` tracks,
|
||||
so a long first run is visible in open browser tabs as it goes rather than only
|
||||
once it finishes.
|
||||
"""
|
||||
analyzer = self.analyzer
|
||||
if analyzer.version < ANALYZER_VERSION:
|
||||
return 0
|
||||
pending = deque(self._pending_tracks(kinds, analyzer.version))
|
||||
if not pending:
|
||||
return 0
|
||||
|
||||
workers = self.analysis_workers if workers is None else max(1, workers)
|
||||
total = len(pending)
|
||||
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
|
||||
done = 0
|
||||
last_report = time.monotonic()
|
||||
try:
|
||||
while pending:
|
||||
while is_busy():
|
||||
# Waited out *between* pools, so a half-hour album is not played
|
||||
# with a houseful of idle worker processes holding onto librosa.
|
||||
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
||||
with analysis_pool(workers) as pool:
|
||||
try:
|
||||
while in_flight or (pending and not is_busy()):
|
||||
while pending and len(in_flight) < workers and not is_busy():
|
||||
key, path = pending.popleft()
|
||||
in_flight[self._submit(pool, loop, path)] = (key, path)
|
||||
if not in_flight:
|
||||
break # gone busy: drop the pool and wait above
|
||||
finished, _ = await asyncio.wait(
|
||||
in_flight, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
for future in finished:
|
||||
key, path = in_flight.pop(future)
|
||||
self._store_result(key, path, future)
|
||||
done += 1
|
||||
if done % batch_size == 0:
|
||||
await self._publish_analysis(kinds, on_batch)
|
||||
now = time.monotonic()
|
||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||
_log.info("Analyzing library: %d/%d tracks done", done, total)
|
||||
last_report = now
|
||||
finally:
|
||||
# Nothing is left to read these - without this, a pass that ends
|
||||
# early (cancelled at shutdown, or a dead pool) leaves "exception
|
||||
# was never retrieved" behind for every track still in flight.
|
||||
for future in in_flight:
|
||||
_discard(future)
|
||||
in_flight.clear()
|
||||
except BrokenExecutor:
|
||||
_log.error(
|
||||
"An analysis worker process died (out of memory?) after %d of %d tracks; "
|
||||
"stopping this pass. The rest are retried on the next one.",
|
||||
done,
|
||||
total,
|
||||
)
|
||||
if done % batch_size:
|
||||
await self._publish_analysis(kinds, on_batch)
|
||||
if done:
|
||||
_log.info("Analyzed %d tracks", done)
|
||||
return done
|
||||
|
||||
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
|
||||
"""The whole pass's worklist, as (cache key, path), worked out up front.
|
||||
|
||||
Up front rather than per track, because a pool has to have the next file ready
|
||||
the moment a worker frees up, and because it is what lets the progress line say
|
||||
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
|
||||
two workers analyzing it at once would be pure waste.
|
||||
"""
|
||||
worklist: list[tuple[str, Path]] = []
|
||||
seen: set[str] = set()
|
||||
for album in self.albums:
|
||||
if album.kind not in kinds:
|
||||
continue
|
||||
for track in album.tracks:
|
||||
key = track_key(track.path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cached = self.cache.load_analysis(key)
|
||||
if cached is not None and cached.version >= version:
|
||||
continue
|
||||
worklist.append((key, track.path))
|
||||
return worklist
|
||||
|
||||
def _submit(
|
||||
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
|
||||
) -> asyncio.Future[Any]:
|
||||
"""Start one track, in a worker process or - with no pool - on a thread here."""
|
||||
if pool is None:
|
||||
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
|
||||
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
|
||||
|
||||
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
|
||||
"""Persist one finished track. Re-raises only what ends the whole pass."""
|
||||
error = future.exception()
|
||||
if isinstance(error, BrokenExecutor):
|
||||
raise error
|
||||
if error is not None:
|
||||
_log.warning(
|
||||
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
||||
path,
|
||||
exc_info=error,
|
||||
)
|
||||
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
|
||||
else:
|
||||
analysis, grid, curve = future.result()
|
||||
if grid is not None:
|
||||
self.cache.store_beats(key, grid)
|
||||
if curve is not None:
|
||||
self.cache.store_curve(key, curve)
|
||||
self.cache.store_analysis(key, analysis)
|
||||
|
||||
async def _publish_analysis(
|
||||
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
||||
) -> None:
|
||||
self._entries = await asyncio.to_thread(self._with_analysis, self._entries, kinds=kinds)
|
||||
await asyncio.to_thread(self.cache.store_index, self._entries)
|
||||
if on_batch is not None:
|
||||
await on_batch()
|
||||
179
python-backend/musicmouse/library/analysis.py
Normal file
179
python-backend/musicmouse/library/analysis.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Offline audio analysis, and the seam that lets it be optional.
|
||||
|
||||
:class:`LibrosaAnalyzer` (``musicmouse.library.librosa_analyzer``) does the real work,
|
||||
kept in its own module behind :func:`build_analyzer` so that importing *this* module -
|
||||
which the scanner, the cache and the web API all do - never pulls in librosa or numpy.
|
||||
:class:`MusicLibrary` runs whichever analyzer it is given in the background, between
|
||||
tracks, so the reactive background is a thing a library grows into rather than a
|
||||
migration.
|
||||
|
||||
Two rules hold the result shape together:
|
||||
|
||||
* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM
|
||||
has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of
|
||||
megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat
|
||||
grid and the per-second :class:`TrackCurves` each live in their own file and are
|
||||
fetched only for the one track that is playing.
|
||||
* **Every field is optional with a default.** Adding a new scalar later needs no
|
||||
migration and no cache wipe: unknown keys on disk are dropped on load, missing ones
|
||||
fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file
|
||||
records marks that file stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Protocol
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ANALYZER_VERSION",
|
||||
"Analyzer",
|
||||
"BeatGrid",
|
||||
"NullAnalyzer",
|
||||
"TrackAnalysis",
|
||||
"TrackCurves",
|
||||
"build_analyzer",
|
||||
]
|
||||
|
||||
#: Bumped when an analyzer's output changes meaning. Cached results recorded under a
|
||||
#: lower version are recomputed; results at or above it are left alone. 2: energy/
|
||||
#: valence scalars became mean-of-curve instead of whole-track-percentile/heuristic,
|
||||
#: and every track needs a new TrackCurves artifact producing.
|
||||
ANALYZER_VERSION: Final = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackAnalysis:
|
||||
"""What an animation may want to know about a track, in a few dozen bytes."""
|
||||
|
||||
#: The :data:`ANALYZER_VERSION` that produced this. ``0`` means "never analyzed".
|
||||
version: int = 0
|
||||
tempo: float | None = None
|
||||
#: 0..1 overall loudness and drive.
|
||||
energy: float | None = None
|
||||
#: 0..1 mood, dark and calm through bright and happy.
|
||||
valence: float | None = None
|
||||
#: 0..1 spectral centroid. Drives the background hue.
|
||||
brightness: float | None = None
|
||||
#: 0..1 confidence that :attr:`tempo` is an audible, steady beat rather than an
|
||||
#: artifact of free-tempo or spoken-word material. Below-threshold tracks should
|
||||
#: not be pulsed on the beat even though a grid exists for them.
|
||||
pulse: float | None = None
|
||||
#: Whether a beat grid for this track exists on disk.
|
||||
beats: bool = False
|
||||
|
||||
@property
|
||||
def is_analyzed(self) -> bool:
|
||||
return self.version >= ANALYZER_VERSION
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> TrackAnalysis:
|
||||
"""Load leniently: unknown keys are dropped, missing keys take their default.
|
||||
|
||||
This is what makes the cache survive an analyzer that grew a field.
|
||||
"""
|
||||
known = {f.name for f in fields(cls)}
|
||||
return cls(**{k: v for k, v in data.items() if k in known})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BeatGrid:
|
||||
"""Beat onsets in seconds with a 0..1 strength each, for beat-synced animation."""
|
||||
|
||||
times: tuple[float, ...]
|
||||
strengths: tuple[float, ...]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
# Flat pairs: half the JSON punctuation of a list of objects.
|
||||
return {"beats": [x for pair in zip(self.times, self.strengths, strict=True) for x in pair]}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> BeatGrid:
|
||||
flat: list[float] = data["beats"]
|
||||
return cls(tuple(flat[0::2]), tuple(flat[1::2]))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackCurves:
|
||||
"""Per-second samples of the things that vary *within* a track. Regularly
|
||||
sampled, so just a hop and equal-length arrays, no per-sample timestamps -
|
||||
contrast :class:`BeatGrid`'s event-based irregular times.
|
||||
|
||||
``energy``/``valence`` drive colour; ``drive`` (rhythmic intensity) modulates the
|
||||
water current's magnitude. Tempo is deliberately absent: measured against a real
|
||||
library it is flat to within a few percent inside a track, so a per-second tempo
|
||||
curve would carry estimator noise (including occasional octave errors) and
|
||||
nothing else. Whole-track tempo keeps setting the current's base magnitude;
|
||||
``drive`` is the signal that actually varies.
|
||||
"""
|
||||
|
||||
hop_seconds: float
|
||||
energy: tuple[float, ...]
|
||||
valence: tuple[float, ...]
|
||||
drive: tuple[float, ...]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"hop_seconds": self.hop_seconds,
|
||||
"energy": list(self.energy),
|
||||
"valence": list(self.valence),
|
||||
"drive": list(self.drive),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> TrackCurves:
|
||||
return cls(
|
||||
hop_seconds=float(data["hop_seconds"]),
|
||||
energy=tuple(data["energy"]),
|
||||
valence=tuple(data["valence"]),
|
||||
drive=tuple(data["drive"]),
|
||||
)
|
||||
|
||||
|
||||
class Analyzer(Protocol):
|
||||
"""Turns one audio file into cacheable analysis results.
|
||||
|
||||
Implementations are CPU-bound and run off the event loop.
|
||||
"""
|
||||
|
||||
version: int
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: ...
|
||||
|
||||
|
||||
class NullAnalyzer:
|
||||
"""Analyzes nothing. What :func:`build_analyzer` falls back to without librosa."""
|
||||
|
||||
version = 0
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
path: Path, # noqa: ARG002
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
return TrackAnalysis(), None, None
|
||||
|
||||
|
||||
def build_analyzer() -> Analyzer:
|
||||
"""The real analyzer if its optional dependency group is installed, else a no-op.
|
||||
|
||||
``NullAnalyzer.version`` is ``0``, which is below :data:`ANALYZER_VERSION`, so
|
||||
:meth:`~musicmouse.library.MusicLibrary.analyze_pending` returns immediately and the
|
||||
background stays at its static baseline - not a crash, not a degraded mode, just the
|
||||
feature switched off until ``pip install -e '.[analysis]'`` turns it on.
|
||||
"""
|
||||
try:
|
||||
from musicmouse.library.librosa_analyzer import LibrosaAnalyzer
|
||||
except ImportError:
|
||||
_log.info(
|
||||
"librosa is not installed: the reactive background is off. "
|
||||
"Install the 'analysis' extra to enable it."
|
||||
)
|
||||
return NullAnalyzer()
|
||||
return LibrosaAnalyzer()
|
||||
322
python-backend/musicmouse/library/cache.py
Normal file
322
python-backend/musicmouse/library/cache.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Where scan results live between runs.
|
||||
|
||||
A directory rather than a single file, because the three kinds of content cost wildly
|
||||
different amounts to produce::
|
||||
|
||||
<cache_dir>/
|
||||
├── index.json cheap: tags and structure. Thrown away freely.
|
||||
├── covers/<album_id>.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows
|
||||
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
|
||||
│ frame, or copied from a cover.jpg in the
|
||||
│ folder - downscaled to MAX_COVER_PX on
|
||||
│ the way in. An album's `cover` always
|
||||
│ points here and never into the library,
|
||||
│ so nothing can serve full-size art by
|
||||
│ accident.
|
||||
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
||||
analysis/<track_key>.beats.json
|
||||
analysis/<track_key>.curve.json
|
||||
|
||||
That split is the whole point. A rescan must be free to rebuild ``index.json`` without
|
||||
destroying analysis, so everything expensive is keyed by a *content* key (see
|
||||
:func:`~musicmouse.library.models.track_key`) rather than by album id - renaming a
|
||||
folder or re-sorting a section then costs nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
||||
from musicmouse.library.models import Album, LibraryTrack
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Fingerprint", "LibraryCache"]
|
||||
|
||||
#: Bump whenever the scanner's *derivation* changes - how a title, artist or series is
|
||||
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
||||
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
||||
#: edits their music folder.
|
||||
# 5: every album's `cover` now points into this cache, downscaled, including the ones
|
||||
# that used to point straight at a `cover.jpg` in the library folder.
|
||||
# 6: MAX_COVER_PX dropped from 640 to 384. This is what applies it. `shrink_cover` never
|
||||
# scales art *up*, so a cover already written at some size is only ever rewritten
|
||||
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
||||
# re-reading them, only by going back to the original art. Discarding the index does
|
||||
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
||||
# 7: a figure's albums may sit in subfolders. `figure` is now the folder directly under
|
||||
# Figuren rather than the album folder's own name, and such an album is titled
|
||||
# "Fuchs - <folder>" - neither of which an entry cached before can know.
|
||||
# The same rescan retired `shrink_stored_covers`, which re-read every cover header on
|
||||
# every scan to catch pre-384 px covers and covers stored before thumbnails existed.
|
||||
#
|
||||
# Bumping this is cheap by design - see the module docstring.
|
||||
_INDEX_VERSION = 7
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fingerprint:
|
||||
"""What makes a folder's cached entry still valid: its files, sizes and mtimes."""
|
||||
|
||||
files: tuple[tuple[str, int, int], ...]
|
||||
|
||||
@classmethod
|
||||
def of(cls, paths: list[Path]) -> Fingerprint:
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
entries.append((path.name, stat.st_size, int(stat.st_mtime)))
|
||||
return cls(tuple(entries))
|
||||
|
||||
def to_json(self) -> list[list[Any]]:
|
||||
return [list(entry) for entry in self.files]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: list[list[Any]]) -> Fingerprint:
|
||||
return cls(tuple((str(n), int(s), int(m)) for n, s, m in data))
|
||||
|
||||
|
||||
def _write_atomic(path: Path, payload: str) -> None:
|
||||
"""Write via a sibling temp file so a crash never leaves a half-written cache."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
temp.write_text(payload, encoding="utf-8")
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
class LibraryCache:
|
||||
def __init__(self, directory: Path) -> None:
|
||||
self.directory = directory
|
||||
self.covers = directory / "covers"
|
||||
self.analysis = directory / "analysis"
|
||||
|
||||
def prepare(self) -> None:
|
||||
for folder in (self.directory, self.covers, self.analysis):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -------------------------------------------------------------------- covers
|
||||
|
||||
def cover_path(self, album_id: str) -> Path:
|
||||
return self.covers / f"{album_id}.jpg"
|
||||
|
||||
def thumb_path(self, album_id: str) -> Path:
|
||||
return self.covers / f"{album_id}.thumb.jpg"
|
||||
|
||||
def store_cover(self, album_id: str, data: bytes) -> Path:
|
||||
path = self.cover_path(album_id)
|
||||
# The thumb comes from the original art, not from the 384 px copy: one lossy
|
||||
# generation fewer, and `Image.draft` lets JPEG decode at a fraction of the size.
|
||||
path.write_bytes(shrink_cover(data))
|
||||
self.thumb_path(album_id).write_bytes(
|
||||
shrink_cover(data, max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
|
||||
)
|
||||
return path
|
||||
|
||||
# ------------------------------------------------------------------ analysis
|
||||
|
||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||
path = self.analysis / f"{key}.json"
|
||||
try:
|
||||
return TrackAnalysis.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def store_analysis(self, key: str, analysis: TrackAnalysis) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.json", json.dumps(analysis.to_json()))
|
||||
|
||||
def load_beats(self, key: str) -> BeatGrid | None:
|
||||
path = self.analysis / f"{key}.beats.json"
|
||||
try:
|
||||
return BeatGrid.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def store_beats(self, key: str, grid: BeatGrid) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json()))
|
||||
|
||||
def load_curve(self, key: str) -> TrackCurves | None:
|
||||
path = self.analysis / f"{key}.curve.json"
|
||||
try:
|
||||
return TrackCurves.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def store_curve(self, key: str, curve: TrackCurves) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.curve.json", json.dumps(curve.to_json()))
|
||||
|
||||
# --------------------------------------------------------------------- index
|
||||
|
||||
def load_index(self) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
path = self.directory / "index.json"
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if raw.get("version") != _INDEX_VERSION:
|
||||
_log.info("Library index is from an older version; rescanning from scratch")
|
||||
return {}
|
||||
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for entry in raw.get("albums", []):
|
||||
try:
|
||||
out[entry["id"]] = (_album_from_json(entry), Fingerprint.from_json(entry["files"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
_log.debug("Dropping unreadable index entry %r", entry.get("id"))
|
||||
return out
|
||||
|
||||
def store_index(self, albums: dict[str, tuple[Album, Fingerprint]]) -> None:
|
||||
payload = {
|
||||
"version": _INDEX_VERSION,
|
||||
"albums": [
|
||||
_album_to_json(album) | {"files": fingerprint.to_json()}
|
||||
for album, fingerprint in albums.values()
|
||||
],
|
||||
}
|
||||
self.prepare()
|
||||
_write_atomic(self.directory / "index.json", json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ serialisation
|
||||
|
||||
|
||||
def _album_to_json(album: Album) -> dict[str, Any]:
|
||||
return {
|
||||
"id": album.id,
|
||||
"section": album.section,
|
||||
"kind": album.kind,
|
||||
"title": album.title,
|
||||
"artist": album.artist,
|
||||
"series": album.series,
|
||||
"figure": album.figure,
|
||||
"colors": list(album.colors),
|
||||
"folder": str(album.folder),
|
||||
"cover": str(album.cover) if album.cover else None,
|
||||
"tracks": [
|
||||
{
|
||||
"path": str(track.path),
|
||||
"title": track.title,
|
||||
"duration": track.duration,
|
||||
"analysis": track.analysis.to_json() if track.analysis else None,
|
||||
}
|
||||
for track in album.tracks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _album_from_json(data: dict[str, Any]) -> Album:
|
||||
section = str(data["section"])
|
||||
if section not in SECTIONS:
|
||||
raise ValueError(f"unknown section {section!r}")
|
||||
# Read back what was stored rather than re-deriving it from the section: a figure's
|
||||
# kind comes from the config, so the section cannot answer for it.
|
||||
kind = str(data["kind"])
|
||||
if kind not in ("music", "book"):
|
||||
raise ValueError(f"unknown kind {kind!r}")
|
||||
red, green, blue = data["colors"]
|
||||
return Album(
|
||||
id=str(data["id"]),
|
||||
section=section,
|
||||
kind=cast("AlbumKind", kind),
|
||||
title=str(data["title"]),
|
||||
artist=str(data["artist"]),
|
||||
series=data["series"],
|
||||
figure=data["figure"],
|
||||
colors=(str(red), str(green), str(blue)),
|
||||
folder=Path(data["folder"]),
|
||||
cover=Path(data["cover"]) if data["cover"] else None,
|
||||
tracks=tuple(
|
||||
LibraryTrack(
|
||||
path=Path(track["path"]),
|
||||
title=str(track["title"]),
|
||||
duration=float(track["duration"]),
|
||||
analysis=(
|
||||
TrackAnalysis.from_json(track["analysis"]) if track.get("analysis") else None
|
||||
),
|
||||
)
|
||||
for track in data["tracks"]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- covers
|
||||
|
||||
#: Longest edge kept for cached album art, in pixels.
|
||||
#:
|
||||
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
|
||||
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
|
||||
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
|
||||
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
|
||||
#:
|
||||
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
|
||||
#: asks for 180 and the player bar for 56), so this only has to beat 340 to be lossless
|
||||
#: where it shows.
|
||||
#:
|
||||
#: It was 640 first, for headroom on a tablet at devicePixelRatio 2, and that headroom
|
||||
#: turned out to be expensive on the device that actually runs this. Measured on
|
||||
#: musicdolphin, typing "conni" over a 343-album library, keydown to painted, worst
|
||||
#: keystroke of three runs: 3.4-9.1 s at 640 px against 0.2 s at 384 px. Decode cost
|
||||
#: goes with pixel count, and a browse grid paints a card 132 px wide.
|
||||
#:
|
||||
#: A tablet at devicePixelRatio 2 therefore gets a slightly soft cover on the play view
|
||||
#: and nowhere else. That is the trade: a visible sharpness margin nobody asked for, for
|
||||
#: a search box that answers a keystroke in a frame.
|
||||
MAX_COVER_PX: Final = 384
|
||||
|
||||
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
|
||||
#: file is a third of the size.
|
||||
_COVER_JPEG_QUALITY: Final = 85
|
||||
|
||||
#: Longest edge of the card/row thumbnail. A grid card is about 217 CSS px wide on a
|
||||
#: 1080p kiosk (five columns in the 1180 px grid), so 256 covers it at devicePixelRatio 1
|
||||
#: with a little to spare; only the play view asks for the full-size file. Decode cost
|
||||
#: and texture upload go with pixel count, and this is 44% of the 384 px file's.
|
||||
THUMB_COVER_PX: Final = 256
|
||||
|
||||
#: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
|
||||
#: a browser on a Pi has, so JPEG stays the format; only the dimensions shrink.
|
||||
_THUMB_JPEG_QUALITY: Final = 80
|
||||
|
||||
|
||||
def shrink_cover(
|
||||
data: bytes, max_px: int = MAX_COVER_PX, quality: int = _COVER_JPEG_QUALITY
|
||||
) -> bytes:
|
||||
"""Downscale cover art to ``max_px`` (default :data:`MAX_COVER_PX`) on its longest edge.
|
||||
|
||||
Art that is already small enough is returned untouched rather than re-encoded, so
|
||||
repeated scans never degrade it. Anything Pillow cannot read is passed through
|
||||
unchanged: a cover that is too big is a performance problem, a cover that is missing
|
||||
is a visible one.
|
||||
|
||||
The output is always a baseline (non-progressive) 4:2:0 JPEG: the cheapest layout
|
||||
for a browser to decode.
|
||||
"""
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as source:
|
||||
if max(source.size) <= max_px:
|
||||
return data
|
||||
# JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats.
|
||||
source.draft("RGB", (max_px, max_px))
|
||||
image = source.convert("RGB")
|
||||
# `thumbnail` keeps the aspect ratio and never scales up.
|
||||
image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
image.save(
|
||||
buffer,
|
||||
format="JPEG",
|
||||
quality=quality,
|
||||
optimize=True,
|
||||
progressive=False,
|
||||
subsampling="4:2:0",
|
||||
)
|
||||
return buffer.getvalue()
|
||||
except (OSError, UnidentifiedImageError, ValueError):
|
||||
return data
|
||||
82
python-backend/musicmouse/library/colors.py
Normal file
82
python-backend/musicmouse/library/colors.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Three colours per album, from its cover art.
|
||||
|
||||
The frontend paints an album card with these and the LED strips run an effect in them,
|
||||
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
||||
happens when an album is scanned; the result is stored in the cached index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import hashlib
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import cast
|
||||
|
||||
from musicmouse.library.models import AlbumColors
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["colors_from_cover", "colors_from_id"]
|
||||
|
||||
#: Quantize to this many candidates before ranking. More just returns near-duplicates.
|
||||
_PALETTE_SIZE = 8
|
||||
#: Downscale first: colour proportions survive, the decode gets ~100x cheaper.
|
||||
_SAMPLE_SIZE = (64, 64)
|
||||
|
||||
#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a
|
||||
#: candidate has to clear both bars to count as one of an album's colours.
|
||||
_MIN_SATURATION = 0.25
|
||||
_MIN_VALUE = 0.25
|
||||
|
||||
|
||||
def _hex(r: int, g: int, b: int) -> str:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def colors_from_id(album_id: str) -> AlbumColors:
|
||||
"""Synthesise a palette when there is no cover to take one from.
|
||||
|
||||
Analogous plus complementary off one hashed hue: distinct per album, never muddy.
|
||||
"""
|
||||
hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360
|
||||
out: list[str] = []
|
||||
for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)):
|
||||
r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value)
|
||||
out.append(_hex(int(r * 255), int(g * 255), int(b * 255)))
|
||||
return out[0], out[1], out[2]
|
||||
|
||||
|
||||
def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors:
|
||||
"""Rank the cover's dominant colours, dropping the ones that would read as mud.
|
||||
|
||||
Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and-
|
||||
white cover legitimately has no three usable colours.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(image_data)) as image:
|
||||
sample = image.convert("RGB").resize(_SAMPLE_SIZE)
|
||||
quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE)
|
||||
palette: list[int] = quantized.getpalette() or []
|
||||
# On a quantized ("P" mode) image getcolors() yields (count, palette index).
|
||||
# Pillow's annotation covers every mode, hence the cast.
|
||||
counts = cast("list[tuple[int, int]]", quantized.getcolors() or [])
|
||||
except Exception: # pragma: no cover - defensive around arbitrary embedded art
|
||||
_log.debug("Cover of %s could not be read; using a synthesised palette", album_id)
|
||||
return colors_from_id(album_id)
|
||||
|
||||
picked: list[str] = []
|
||||
for _count, index in sorted(counts, reverse=True):
|
||||
r, g, b = palette[index * 3 : index * 3 + 3]
|
||||
_, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||
if saturation < _MIN_SATURATION or value < _MIN_VALUE:
|
||||
continue
|
||||
picked.append(_hex(r, g, b))
|
||||
if len(picked) == 3:
|
||||
return picked[0], picked[1], picked[2]
|
||||
|
||||
fallback = colors_from_id(album_id)
|
||||
filled = picked + list(fallback[len(picked) :])
|
||||
return filled[0], filled[1], filled[2]
|
||||
318
python-backend/musicmouse/library/librosa_analyzer.py
Normal file
318
python-backend/musicmouse/library/librosa_analyzer.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""A concrete :class:`~musicmouse.library.analysis.Analyzer` built on librosa.
|
||||
|
||||
Only reached through :func:`musicmouse.library.analysis.build_analyzer`, so nothing
|
||||
else in the app ever imports librosa or numpy: a device without the ``analysis`` extra
|
||||
installed never executes this module at all.
|
||||
|
||||
Every scalar here is a signal-processing proxy, not a measurement of how a track
|
||||
actually feels - ``valence`` most of all, see its section below. Good enough to drive
|
||||
an ambient background; not a music information retrieval research result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LibrosaAnalyzer"]
|
||||
|
||||
#: librosa's own default. Ample for everything below - the highest band that matters,
|
||||
#: the brightness ceiling, sits well under this rate's 11025 Hz Nyquist frequency.
|
||||
_SAMPLE_RATE = 22050
|
||||
_HOP_LENGTH = 512
|
||||
|
||||
#: `energy`: the 80th-percentile RMS frame, in dB, mapped floor..ceil to 0..1. -32 dB is
|
||||
#: a quiet passage, -8 dB is a hot, compressed master. A percentile rather than the mean
|
||||
#: or max so a quiet intro or a gap between phrases doesn't drag a loud track down, and
|
||||
#: one clipped peak doesn't blow it out.
|
||||
_ENERGY_DB_FLOOR = -32.0
|
||||
_ENERGY_DB_CEIL = -8.0
|
||||
|
||||
#: `brightness`: the median spectral centroid (robust to a single loud transient), in
|
||||
#: Hz, log-mapped floor..ceil to 0..1. 300 Hz is a dark, bass/vocal-heavy mix; 4000 Hz is
|
||||
#: bright, sparkly production.
|
||||
_BRIGHTNESS_HZ_FLOOR = 300.0
|
||||
_BRIGHTNESS_HZ_CEIL = 4000.0
|
||||
|
||||
#: `valence`'s tempo term: 60 BPM reads as a lullaby, 150 BPM as a romp.
|
||||
_TEMPO_BPM_FLOOR = 60.0
|
||||
_TEMPO_BPM_CEIL = 150.0
|
||||
|
||||
#: Key is a global property of a track; the middle minute is its most representative
|
||||
#: one and this keeps the most expensive feature (chroma) off the tail of a long track.
|
||||
_CHROMA_EXCERPT_SECONDS = 120.0
|
||||
|
||||
#: Seconds per curve sample. Fixed at analysis time, not user-facing - changing this
|
||||
#: needs a re-analysis and an ANALYZER_VERSION bump, unlike the frontend's own
|
||||
#: unrelated "curve sample interval" debug slider, which just smooths already-fetched
|
||||
#: samples for live preview.
|
||||
_ANALYSIS_HOP_SECONDS = 1.0
|
||||
|
||||
#: `drive`'s blend of onset activity (steadier, measured 33-55% relative spread across
|
||||
#: a track) and local pulse strength (more dynamic but spikier, 59-189%) - weighted
|
||||
#: toward activity so a busy chorus reads clearly without the current twitching on
|
||||
#: every transient.
|
||||
_DRIVE_ACTIVITY_WEIGHT = 0.6
|
||||
_DRIVE_PLP_WEIGHT = 0.4
|
||||
|
||||
#: Krumhansl-Kessler key profiles (Krumhansl & Kessler 1982), starting from C. Every
|
||||
#: other key is scored by rotating these twelve weights, not by transposing the audio.
|
||||
_MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
_MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
|
||||
def _clip01(value: float) -> float:
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def _normalize(value: float, floor: float, ceil: float) -> float:
|
||||
"""Linear map ``floor..ceil`` to ``0..1``, clipped at both ends."""
|
||||
return _clip01((value - floor) / (ceil - floor))
|
||||
|
||||
|
||||
def _center_excerpt(y: np.ndarray, sr: float, seconds: float) -> np.ndarray:
|
||||
max_samples = int(seconds * sr)
|
||||
if y.size <= max_samples:
|
||||
return y
|
||||
start = (y.size - max_samples) // 2
|
||||
return y[start : start + max_samples]
|
||||
|
||||
|
||||
def _frames_per_window(sr: float, hop_length: int, window_seconds: float) -> int:
|
||||
return max(1, round(window_seconds * sr / hop_length))
|
||||
|
||||
|
||||
def _windowed(
|
||||
values: np.ndarray, frames_per_window: int, reduce: Callable[[np.ndarray], float]
|
||||
) -> np.ndarray:
|
||||
"""Buckets an already-computed per-frame array (RMS, spectral centroid, onset
|
||||
envelope, PLP - anything on the STFT hop grid) into `frames_per_window`-wide
|
||||
windows, applying `reduce` to each. The last window is short rather than dropped,
|
||||
so a track's tail is never silently excluded from its own curve."""
|
||||
n = max(1, math.ceil(values.size / frames_per_window))
|
||||
out = np.empty(n)
|
||||
for i in range(n):
|
||||
w = values[i * frames_per_window : (i + 1) * frames_per_window]
|
||||
out[i] = reduce(w if w.size else values[-1:])
|
||||
return out
|
||||
|
||||
|
||||
def _energy_window(w: np.ndarray) -> float:
|
||||
"""The same 80th-percentile+dB statistic as the whole-track `energy` scalar,
|
||||
applied to one window."""
|
||||
db = librosa.amplitude_to_db(np.array([np.percentile(w, 80)]), ref=1.0)[0]
|
||||
return _normalize(float(db), _ENERGY_DB_FLOOR, _ENERGY_DB_CEIL)
|
||||
|
||||
|
||||
def _brightness_window(w: np.ndarray) -> float:
|
||||
"""The same median+log statistic as the whole-track `brightness` scalar, applied
|
||||
to one window."""
|
||||
hz = max(float(np.median(w)), 1.0) # guard log2(0)
|
||||
floor, ceil = math.log2(_BRIGHTNESS_HZ_FLOOR), math.log2(_BRIGHTNESS_HZ_CEIL)
|
||||
return _normalize(math.log2(hz), floor, ceil)
|
||||
|
||||
|
||||
def _norm95(values: np.ndarray) -> np.ndarray:
|
||||
"""Scale so the array's 95th percentile maps to 1.0 - a robust max that one loud
|
||||
transient can't blow out, the same reasoning as the beat grid's strengths."""
|
||||
ceiling = float(np.percentile(values, 95))
|
||||
return values / ceiling if ceiling > 0 else np.zeros_like(values)
|
||||
|
||||
|
||||
def _majorness(chroma_mean: np.ndarray) -> float:
|
||||
"""Best major key-profile correlation minus best minor one, over all 12 rotations.
|
||||
|
||||
Positive means the track's pitch-class distribution fits a major key better than
|
||||
any minor one; negative the other way round. `np.corrcoef` is undefined for a
|
||||
perfectly flat chroma vector (silence, pure noise) - `nan_to_num` turns that into
|
||||
"no signal either way" rather than raising.
|
||||
"""
|
||||
|
||||
def best_fit(profile: np.ndarray) -> float:
|
||||
return max(
|
||||
float(np.nan_to_num(np.corrcoef(chroma_mean, np.roll(profile, i))[0, 1]))
|
||||
for i in range(12)
|
||||
)
|
||||
|
||||
return best_fit(_MAJOR_PROFILE) - best_fit(_MINOR_PROFILE)
|
||||
|
||||
|
||||
class LibrosaAnalyzer:
|
||||
"""Turns one music file into :class:`TrackAnalysis` plus an optional beat grid
|
||||
and :class:`TrackCurves`."""
|
||||
|
||||
version = ANALYZER_VERSION
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
"""Never raises: a file this can't make sense of is analyzed as "nothing".
|
||||
|
||||
A corrupt file, a DRM'd one, or a zero-length one must not abort a batch of
|
||||
hundreds - the fallback records `version` so :meth:`MusicLibrary.analyze_pending`
|
||||
does not retry it forever, while every scalar stays `None` so the frontend falls
|
||||
back to the un-analyzed baseline look rather than something half-computed.
|
||||
"""
|
||||
try:
|
||||
y, sr = librosa.load(path, sr=_SAMPLE_RATE, mono=True)
|
||||
except Exception:
|
||||
_log.warning("Could not decode %s; leaving it unanalyzed", path, exc_info=True)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
if y.size == 0:
|
||||
_log.warning("%s decoded to no audio; leaving it unanalyzed", path)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
try:
|
||||
return self._analyze(y, sr)
|
||||
except Exception:
|
||||
_log.warning("Analysis failed for %s; leaving it unanalyzed", path, exc_info=True)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
def _analyze(
|
||||
self, y: np.ndarray, sr: float
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
# One onset envelope feeds tempo, the beat grid's strengths, `pulse` and
|
||||
# `drive` - the single most expensive shared computation, so it is done once.
|
||||
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=_HOP_LENGTH)
|
||||
tempo_raw, beat_frames = librosa.beat.beat_track(
|
||||
onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH
|
||||
)
|
||||
tempo_bpm = float(np.atleast_1d(tempo_raw)[0])
|
||||
grid = self._beat_grid(onset_env, beat_frames, sr)
|
||||
|
||||
frames_per_window = _frames_per_window(sr, _HOP_LENGTH, _ANALYSIS_HOP_SECONDS)
|
||||
|
||||
rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
|
||||
energy_curve = _windowed(rms, frames_per_window, _energy_window)
|
||||
energy = float(np.mean(energy_curve))
|
||||
|
||||
centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=_HOP_LENGTH)[0]
|
||||
# Scalar `brightness` stays the whole-track median exactly as before this
|
||||
# refactor - it no longer drives anything on the frontend (only its curve
|
||||
# feeds `valence` below), so its own meaning is deliberately left unchanged.
|
||||
brightness = _brightness_window(centroid)
|
||||
brightness_curve = _windowed(centroid, frames_per_window, _brightness_window)
|
||||
|
||||
majorness_norm, tempo_norm = self._valence_terms(y, sr, tempo_bpm)
|
||||
valence_curve = np.clip(
|
||||
0.5 * majorness_norm + 0.3 * brightness_curve + 0.2 * tempo_norm, 0.0, 1.0
|
||||
)
|
||||
valence = float(np.mean(valence_curve))
|
||||
|
||||
pulse = self._pulse(onset_env, sr, tempo_bpm)
|
||||
drive_curve = self._drive_curve(onset_env, sr, frames_per_window)
|
||||
|
||||
curves = TrackCurves(
|
||||
hop_seconds=_ANALYSIS_HOP_SECONDS,
|
||||
energy=tuple(float(v) for v in energy_curve),
|
||||
valence=tuple(float(v) for v in valence_curve),
|
||||
drive=tuple(float(v) for v in drive_curve),
|
||||
)
|
||||
|
||||
analysis = TrackAnalysis(
|
||||
version=self.version,
|
||||
tempo=tempo_bpm,
|
||||
energy=energy,
|
||||
valence=valence,
|
||||
brightness=brightness,
|
||||
pulse=pulse,
|
||||
beats=grid is not None,
|
||||
)
|
||||
return analysis, grid, curves
|
||||
|
||||
def _beat_grid(
|
||||
self, onset_env: np.ndarray, beat_frames: np.ndarray, sr: float
|
||||
) -> BeatGrid | None:
|
||||
if beat_frames.size == 0:
|
||||
return None
|
||||
times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=_HOP_LENGTH)
|
||||
raw_strengths = onset_env[np.clip(beat_frames, 0, onset_env.size - 1)]
|
||||
# The 95th percentile rather than the max, so one loud crash does not flatten
|
||||
# every other beat's strength toward zero.
|
||||
scale = float(np.percentile(raw_strengths, 95))
|
||||
strengths = raw_strengths / scale if scale > 0 else np.zeros_like(raw_strengths)
|
||||
return BeatGrid(
|
||||
tuple(float(t) for t in times),
|
||||
tuple(_clip01(float(s)) for s in strengths),
|
||||
)
|
||||
|
||||
def _valence_terms(self, y: np.ndarray, sr: float, tempo_bpm: float) -> tuple[float, float]:
|
||||
"""majorness_norm, tempo_norm - the two whole-track-constant terms of the
|
||||
valence formula. See the module docstring for why valence is a heuristic, not
|
||||
a measurement.
|
||||
|
||||
Weighted for a *children's* library specifically: mode (major/minor) is the
|
||||
strongest and most legible cue in this repertoire - a minor-key children's song
|
||||
is almost always deliberately sad or spooky, unlike in pop where mode is a much
|
||||
weaker signal. Tempo adds romp-vs-lullaby. Both stay whole-track constants:
|
||||
key is a global property of a track, and (unlike brightness) chroma-based key
|
||||
detection is too expensive and too noisy over a short window to be worth
|
||||
computing per second for what is now only a secondary, "nudge" contribution to
|
||||
color - see `TrackCurves.valence`.
|
||||
"""
|
||||
excerpt = _center_excerpt(y, sr, _CHROMA_EXCERPT_SECONDS)
|
||||
chroma = librosa.feature.chroma_cqt(y=excerpt, sr=sr, hop_length=_HOP_LENGTH)
|
||||
majorness = _majorness(chroma.mean(axis=1))
|
||||
majorness_norm = _clip01((majorness + 1.0) / 2.0)
|
||||
tempo_norm = _normalize(tempo_bpm, _TEMPO_BPM_FLOOR, _TEMPO_BPM_CEIL)
|
||||
return majorness_norm, tempo_norm
|
||||
|
||||
def _drive_curve(self, onset_env: np.ndarray, sr: float, frames_per_window: int) -> np.ndarray:
|
||||
"""Rhythmic intensity per window, 0..1, normalised *within the track*.
|
||||
|
||||
This - not tempo - is what the frontend's water current breathes with while a
|
||||
track plays. Measured against a real library, local tempo is flat to within a
|
||||
few percent inside a track (recorded children's music is played to a click);
|
||||
the residual "variation" a per-second tempo curve would show is mostly
|
||||
estimator noise, including occasional octave errors. Onset activity and local
|
||||
pulse strength (PLP) both genuinely vary within a track (33-55% and 59-189%
|
||||
relative spread respectively) and don't carry that failure mode.
|
||||
|
||||
Per-track normalisation (each component scaled by its own 95th percentile) is
|
||||
deliberate: absolute "how energetic is this song" is already carried by
|
||||
whole-track `tempo` (the current's base magnitude) and by `energy` (colour).
|
||||
This curve is for relative shape within the track - the intro is calmer than
|
||||
the chorus - so every track uses its own full 0..1 range rather than a
|
||||
uniformly quiet song sitting flat near zero throughout.
|
||||
"""
|
||||
activity = _windowed(onset_env, frames_per_window, lambda w: float(np.mean(w)))
|
||||
plp = librosa.beat.plp(onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH)
|
||||
pulse_curve = _windowed(plp, frames_per_window, lambda w: float(np.mean(w)))
|
||||
activity_term = _DRIVE_ACTIVITY_WEIGHT * _norm95(activity)
|
||||
pulse_term = _DRIVE_PLP_WEIGHT * _norm95(pulse_curve)
|
||||
return np.clip(activity_term + pulse_term, 0.0, 1.0)
|
||||
|
||||
def _pulse(self, onset_env: np.ndarray, sr: float, tempo_bpm: float) -> float:
|
||||
"""0..1 confidence that `tempo` is an audible, steady beat.
|
||||
|
||||
The onset envelope's autocorrelation at the beat period, relative to its value
|
||||
at lag zero: a track that truly pulses at `tempo` has a strong echo of itself
|
||||
one beat later; free-tempo or spoken-word material does not, even though
|
||||
`beat_track` always returns *some* grid for it.
|
||||
"""
|
||||
if tempo_bpm <= 0 or onset_env.size < 2:
|
||||
return 0.0
|
||||
period_frames = round((60.0 / tempo_bpm) * sr / _HOP_LENGTH)
|
||||
if not 0 < period_frames < onset_env.size:
|
||||
return 0.0
|
||||
with warnings.catch_warnings():
|
||||
# A known-spurious warning from numba's complex-magnitude dufunc under
|
||||
# this numba/numpy/librosa combination (confirmed: the input here has no
|
||||
# NaN/Inf, and the result is a normal finite float) - narrowly silenced
|
||||
# rather than left to spam the log once per track analyzed.
|
||||
warnings.filterwarnings(
|
||||
"ignore", message="invalid value encountered in cast", category=RuntimeWarning
|
||||
)
|
||||
ac = librosa.autocorrelate(onset_env)
|
||||
if ac[0] <= 0:
|
||||
return 0.0
|
||||
return _clip01(float(ac[period_frames] / ac[0]))
|
||||
89
python-backend/musicmouse/library/models.py
Normal file
89
python-backend/musicmouse/library/models.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""What the browse API serves: albums of tracks, with the colours to draw them in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
from musicmouse.library.sections import AlbumKind
|
||||
from musicmouse.media import Playlist, Track
|
||||
|
||||
__all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
|
||||
|
||||
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||
#: parsing and the frontend gets CSS colours for free.
|
||||
AlbumColors: TypeAlias = tuple[str, str, str]
|
||||
|
||||
|
||||
def album_id(root: Path, folder: Path) -> str:
|
||||
"""A stable id for an album folder, from its path relative to the library root."""
|
||||
relative = folder.relative_to(root).as_posix()
|
||||
return hashlib.sha1(relative.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def track_key(path: Path) -> str:
|
||||
"""Content key for expensive per-track results.
|
||||
|
||||
Keyed on the *file*, not the album, so renaming a folder or re-sorting a section
|
||||
never throws away analysis that took minutes to compute.
|
||||
"""
|
||||
stat = path.stat()
|
||||
material = f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||
return hashlib.sha1(material.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LibraryTrack:
|
||||
path: Path
|
||||
title: str
|
||||
#: Seconds, from the tags. ``0.0`` when the file carries no duration.
|
||||
duration: float = 0.0
|
||||
analysis: TrackAnalysis | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LibraryTrack({self.title!r}, {self.duration:.0f}s)"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Album:
|
||||
id: str
|
||||
section: str
|
||||
kind: AlbumKind
|
||||
title: str
|
||||
artist: str
|
||||
#: Grouping key for audiobooks and podcasts. ``None`` for music, which groups by
|
||||
#: artist instead - the two cases the browse UI's category view distinguishes.
|
||||
series: str | None
|
||||
#: Figure name when this folder sits under ``Figuren``, else ``None``.
|
||||
figure: str | None
|
||||
colors: AlbumColors
|
||||
folder: Path
|
||||
cover: Path | None
|
||||
tracks: tuple[LibraryTrack, ...]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tracks)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return sum(track.duration for track in self.tracks)
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""How the browse view groups this album: series for books, artist for music."""
|
||||
return self.series or self.artist
|
||||
|
||||
def to_playlist(self) -> Playlist:
|
||||
"""The player's view of this album."""
|
||||
return Playlist(
|
||||
name=self.figure or self.title,
|
||||
tracks=tuple(Track(track.path) for track in self.tracks),
|
||||
album_id=self.id,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Album({self.title!r}, {self.artist!r}, {len(self.tracks)} tracks)"
|
||||
668
python-backend/musicmouse/library/podcast_feeds.py
Normal file
668
python-backend/musicmouse/library/podcast_feeds.py
Normal file
@@ -0,0 +1,668 @@
|
||||
"""Auto-download new episodes for podcast shows that name their RSS feed.
|
||||
|
||||
A show folder opts in by containing a small marker file, ``feed.txt``, whose first
|
||||
non-blank line is the feed's URL. That file is the only thing this module needs to find
|
||||
a show - nothing here is turned on by config, matching how the rest of the library reads
|
||||
its shape entirely off the folder tree (see :mod:`musicmouse.library.sections`).
|
||||
|
||||
Downloaded episodes land in the show folder using the exact ``YYYYMMDD - Title.ext``
|
||||
convention ``sections.py`` already documents for ``Kinderpodcasts``, so a freshly
|
||||
downloaded episode sorts and scans exactly like one a person dropped in by hand. When
|
||||
a feed offers real per-episode artwork, it's saved alongside as a same-named sidecar
|
||||
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
||||
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
||||
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
||||
A show folder is also kept to a fixed number of the newest episodes
|
||||
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
|
||||
The same limit caps what is downloaded, which is what stops the two halves fighting:
|
||||
prune what is older than the newest N, download only the newest N, and the folder
|
||||
settles instead of re-fetching every episode it just deleted.
|
||||
|
||||
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
||||
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import feedparser
|
||||
import httpx2
|
||||
|
||||
from musicmouse.library.sections import SECTIONS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_EPISODE_LIMIT",
|
||||
"FEED_MARKER_NAME",
|
||||
"AudioExtractionError",
|
||||
"Episode",
|
||||
"download_episode",
|
||||
"download_episode_cover",
|
||||
"episode_cover_filename",
|
||||
"episode_filename",
|
||||
"find_feed_shows",
|
||||
"missing_episodes",
|
||||
"newest_episodes",
|
||||
"parse_feed",
|
||||
"prune_show",
|
||||
"resolve_episode_cover",
|
||||
"sync_all_shows",
|
||||
"sync_show",
|
||||
]
|
||||
|
||||
FEED_MARKER_NAME: Final = "feed.txt"
|
||||
|
||||
#: How many episodes of one show to keep, newest first. A show that has published for
|
||||
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
|
||||
#: does not fit next to the rest of the library on a Pi's SD card.
|
||||
#:
|
||||
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
|
||||
#: to the newest N but download everything the feed offers, and every poll would
|
||||
#: re-fetch the episodes the last one deleted, forever.
|
||||
DEFAULT_EPISODE_LIMIT: Final = 50
|
||||
|
||||
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/aac": ".aac",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/opus": ".opus",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
}
|
||||
_DEFAULT_EXTENSION: Final = ".mp3"
|
||||
_KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"})
|
||||
|
||||
#: Cover URL suffix -> file extension, for the per-episode sidecar image.
|
||||
_IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
_DEFAULT_IMAGE_EXTENSION: Final = ".jpg"
|
||||
|
||||
#: Characters illegal (or awkward) in a filename, plus the path separators themselves.
|
||||
#: Replaced with "_" rather than dropped, matching the convention every episode
|
||||
#: already downloaded by hand (via the ``podcast-dl`` CLI) was named with - a
|
||||
#: mismatch here would make every one of them look "new" to `missing_episodes`.
|
||||
_ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]')
|
||||
_MAX_TITLE_LENGTH: Final = 120
|
||||
|
||||
_HTTP_TIMEOUT: Final = 30.0
|
||||
#: `og:image` scraping is a fallback for a page we don't control; kept short so one
|
||||
#: slow or hanging host can't stall a whole sync pass.
|
||||
_OG_IMAGE_TIMEOUT: Final = 15.0
|
||||
_OG_IMAGE_RE: Final = re.compile(
|
||||
r'<meta[^>]+property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']'
|
||||
r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: How long a video enclosure is given to download-and-transcode before it's treated
|
||||
#: as failed - generous, since this runs on a Pi and a long video can take a while.
|
||||
_FFMPEG_TIMEOUT: Final = 600.0
|
||||
|
||||
#: Where a show folder remembers episodes that failed to download, so a permanently
|
||||
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
||||
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
||||
#: The same, for episodes on disk whose cover could not be found or fetched - so an
|
||||
#: episode with no art anywhere doesn't cost a page fetch on every poll.
|
||||
_MISSING_COVERS_FILENAME: Final = ".missing-covers.json"
|
||||
#: How long a failed episode is left alone before it's given another chance - long
|
||||
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
||||
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
||||
_RETRY_BACKOFF: Final = timedelta(days=7)
|
||||
|
||||
|
||||
class AudioExtractionError(Exception):
|
||||
"""Raised when a video enclosure could not be turned into an audio file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Episode:
|
||||
title: str
|
||||
published: datetime
|
||||
enclosure_url: str
|
||||
enclosure_type: str
|
||||
#: The feed's own per-episode artwork, when it has one distinct from the show's
|
||||
#: overall cover. ``None`` when the feed has no image at all, or (as with GEOlino)
|
||||
#: every item merely repeats the channel's own image.
|
||||
cover_url: str | None = None
|
||||
#: The episode's own page, when the feed links to one distinct from the show's
|
||||
#: general page - the fallback route to real per-episode art for a feed (like
|
||||
#: Wissen macht Ah) that has no per-item image of its own, via that page's
|
||||
#: `og:image`. ``None`` when the feed has no such per-episode page.
|
||||
link: str | None = None
|
||||
|
||||
|
||||
def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]:
|
||||
"""Every show folder with a feed marker, as ``(section_name, folder, feed_url)``.
|
||||
|
||||
Restricted to sections whose ``album_unit`` is ``"episode"`` (today just
|
||||
``Kinderpodcasts``) - that is what marks a folder as a show rather than a single
|
||||
release, the same distinction the scanner already makes.
|
||||
"""
|
||||
out: list[tuple[str, Path, str]] = []
|
||||
for section_name, section in SECTIONS.items():
|
||||
if section.album_unit != "episode":
|
||||
continue
|
||||
section_root = root / section_name
|
||||
if not section_root.is_dir():
|
||||
continue
|
||||
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||
if not folder.is_dir() or folder.name.startswith("."):
|
||||
continue
|
||||
marker = folder / FEED_MARKER_NAME
|
||||
if not marker.is_file():
|
||||
continue
|
||||
feed_url = _read_feed_url(marker)
|
||||
if feed_url is not None:
|
||||
out.append((section_name, folder, feed_url))
|
||||
return out
|
||||
|
||||
|
||||
def _read_feed_url(marker: Path) -> str | None:
|
||||
for line in marker.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
_log.warning("%s is empty; no feed URL to read", marker)
|
||||
return None
|
||||
|
||||
|
||||
def parse_feed(content: bytes) -> list[Episode]:
|
||||
"""Every episode in a feed that has both a publish date and a playable enclosure.
|
||||
|
||||
Anything else is skipped and logged rather than raising - a malformed or unusual
|
||||
entry in one feed must never stop the episodes around it from being picked up.
|
||||
"""
|
||||
parsed = feedparser.parse(content)
|
||||
# feedparser exposes an `itunes:image` identically at the channel and item level,
|
||||
# under the same "image" key an ordinary RSS `<image>` uses - there is no separate
|
||||
# `itunes_image` field. Read the channel's own image/link once so each entry can
|
||||
# tell whether it has something genuinely its own, or is just repeating them
|
||||
# (as GEOlino's per-item `itunes:image` does).
|
||||
channel_image = parsed.feed.get("image", {}).get("href")
|
||||
channel_link = parsed.feed.get("link")
|
||||
episodes: list[Episode] = []
|
||||
for entry in parsed.entries:
|
||||
published = _entry_published(entry)
|
||||
if published is None:
|
||||
_log.debug("Feed entry %r has no publish date; skipping", entry.get("title"))
|
||||
continue
|
||||
enclosure = _entry_enclosure(entry)
|
||||
if enclosure is None:
|
||||
_log.debug(
|
||||
"Feed entry %r has no audio or video enclosure; skipping", entry.get("title")
|
||||
)
|
||||
continue
|
||||
url, enclosure_type = enclosure
|
||||
|
||||
item_image = entry.get("image", {}).get("href")
|
||||
cover_url = item_image if item_image and item_image != channel_image else None
|
||||
|
||||
item_link = entry.get("link")
|
||||
link = item_link if item_link and item_link != channel_link else None
|
||||
|
||||
episodes.append(
|
||||
Episode(
|
||||
title=entry.get("title") or url,
|
||||
published=published,
|
||||
enclosure_url=url,
|
||||
enclosure_type=enclosure_type,
|
||||
cover_url=cover_url,
|
||||
link=link,
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
|
||||
def _entry_published(entry: Any) -> datetime | None:
|
||||
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
|
||||
if parsed_time is None:
|
||||
return None
|
||||
year, month, day, hour, minute, second = tuple(parsed_time)[:6]
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=UTC)
|
||||
|
||||
|
||||
def _entry_enclosure(entry: Any) -> tuple[str, str] | None:
|
||||
"""The enclosure to download for one entry, preferring audio when both are offered.
|
||||
|
||||
A video enclosure (some shows, like Wissen macht Ah, publish no audio version at
|
||||
all) is still returned rather than dropped - :func:`download_episode` turns it
|
||||
into audio via ffmpeg. It's just the least preferred of the three: an audio
|
||||
enclosure, or one with no declared type at all (assumed audio), both win outright.
|
||||
"""
|
||||
video: tuple[str, str] | None = None
|
||||
for enclosure in entry.get("enclosures", []):
|
||||
url = enclosure.get("href") or enclosure.get("url")
|
||||
enclosure_type = enclosure.get("type") or ""
|
||||
if not url:
|
||||
continue
|
||||
if enclosure_type.startswith("audio/") or not enclosure_type:
|
||||
return str(url), str(enclosure_type)
|
||||
if video is None and enclosure_type.startswith("video/"):
|
||||
video = (str(url), str(enclosure_type))
|
||||
return video
|
||||
|
||||
|
||||
def _episode_stem(published: datetime, title: str) -> str:
|
||||
"""``YYYYMMDD - Title``, with no extension yet - shared by the audio filename and
|
||||
its sidecar cover image, so the two always line up."""
|
||||
sanitized = _ILLEGAL_FILENAME_CHARS.sub("_", title).strip().strip(".")
|
||||
sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode"
|
||||
return f"{published:%Y%m%d} - {sanitized}"
|
||||
|
||||
|
||||
def episode_filename(
|
||||
published: datetime, title: str, enclosure_type: str, enclosure_url: str
|
||||
) -> str:
|
||||
"""``YYYYMMDD - Title.ext``, matching the convention every hand-placed episode
|
||||
already follows (see ``sections.py``'s module comment on ``Kinderpodcasts``)."""
|
||||
return f"{_episode_stem(published, title)}{_extension_for(enclosure_type, enclosure_url)}"
|
||||
|
||||
|
||||
def episode_cover_filename(published: datetime, title: str, cover_url: str) -> str:
|
||||
"""The sidecar image filename for an episode's cover, sharing its stem so the
|
||||
scanner (``scanner.py``'s ``_cover_for_episode``) can find it next to the audio."""
|
||||
return f"{_episode_stem(published, title)}{_image_extension_for(cover_url)}"
|
||||
|
||||
|
||||
def _extension_for(enclosure_type: str, url: str) -> str:
|
||||
suffix = Path(urlsplit(url).path).suffix.lower()
|
||||
if suffix in _KNOWN_EXTENSIONS:
|
||||
return suffix
|
||||
return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_EXTENSION)
|
||||
|
||||
|
||||
def _image_extension_for(url: str) -> str:
|
||||
suffix = Path(urlsplit(url).path).suffix.lower()
|
||||
return suffix if suffix in _IMAGE_EXTENSIONS else _DEFAULT_IMAGE_EXTENSION
|
||||
|
||||
|
||||
def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episode, str]]:
|
||||
"""Episodes whose target filename isn't already on disk, paired with that filename.
|
||||
|
||||
Dedup is purely by filename - no separate manifest of what has been downloaded
|
||||
before, matching how the rest of the library already treats the filesystem as the
|
||||
only source of truth (see the fingerprinting in ``cache.py``).
|
||||
"""
|
||||
out: list[tuple[Episode, str]] = []
|
||||
for episode in episodes:
|
||||
filename = episode_filename(
|
||||
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||
)
|
||||
if not (folder / filename).exists():
|
||||
out.append((episode, filename))
|
||||
return out
|
||||
|
||||
|
||||
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
|
||||
"""The ``keep`` most recently published episodes, newest first.
|
||||
|
||||
Applied to the *feed* before anything is downloaded. Without it, capping the folder
|
||||
would be pointless: the next poll would see every pruned episode as missing again.
|
||||
"""
|
||||
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
|
||||
return ordered if keep is None else ordered[:keep]
|
||||
|
||||
|
||||
def _episode_date(name: str) -> datetime | None:
|
||||
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
|
||||
|
||||
Deliberately strict. A file that does not follow the convention is one this module
|
||||
did not write - something dropped in by hand under another name - and it is left
|
||||
alone rather than guessed at, because the alternative is deleting somebody's file
|
||||
on a parse that happened to fail.
|
||||
"""
|
||||
stem = Path(name).stem
|
||||
if len(stem) < 8 or not stem[:8].isdigit():
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
|
||||
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
|
||||
|
||||
Only touches files named the way :func:`episode_filename` names them, so a
|
||||
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
|
||||
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
|
||||
and leaving it behind would strand art for a track that no longer exists.
|
||||
|
||||
``keep`` of ``None`` disables pruning entirely.
|
||||
"""
|
||||
if keep is None:
|
||||
return 0
|
||||
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
|
||||
|
||||
dated: list[tuple[datetime, Path]] = []
|
||||
for path in folder.iterdir():
|
||||
if not path.is_file() or path.suffix.lower() not in audio_extensions:
|
||||
continue
|
||||
published = _episode_date(path.name)
|
||||
if published is not None:
|
||||
dated.append((published, path))
|
||||
|
||||
if len(dated) <= keep:
|
||||
return 0
|
||||
|
||||
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||
removed = 0
|
||||
for _published, path in dated[keep:]:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
_log.warning("Could not delete old episode %s: %s", path, exc)
|
||||
continue
|
||||
removed += 1
|
||||
for image_extension in _IMAGE_EXTENSIONS:
|
||||
sidecar = path.with_suffix(image_extension)
|
||||
try:
|
||||
sidecar.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
_log.debug("Could not delete cover %s: %s", sidecar, exc)
|
||||
if removed:
|
||||
_log.info(
|
||||
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
|
||||
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
|
||||
until :data:`_RETRY_BACKOFF` has passed. ``record_name`` picks which record: failed
|
||||
downloads, or covers that could not be found.
|
||||
|
||||
A missing or corrupt record is just an empty one - nothing here is precious enough
|
||||
to raise over, matching the module's overall rule that bad input is logged and
|
||||
skipped rather than fatal.
|
||||
"""
|
||||
path = folder / record_name
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, ValueError) as exc:
|
||||
_log.debug("Could not read %s; treating as empty: %s", path, exc)
|
||||
return {}
|
||||
try:
|
||||
return {str(filename): datetime.fromisoformat(when) for filename, when in raw.items()}
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
_log.debug("Could not parse %s; treating as empty: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
|
||||
path = folder / record_name
|
||||
if not failed:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
temp_path = folder / f".{record_name}.tmp"
|
||||
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
||||
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temp_path.replace(path)
|
||||
|
||||
|
||||
async def download_episode(
|
||||
client: httpx2.AsyncClient, folder: Path, episode: Episode, filename: str
|
||||
) -> None:
|
||||
"""Get an episode to ``folder / filename`` via a dotfile temp path.
|
||||
|
||||
A half-written file must never look like a track: the scanner already skips
|
||||
dotfiles for exactly this reason (see ``scanner.py``'s ``_audio_files``), so the
|
||||
rename to the real name only happens once the download is complete. A video
|
||||
enclosure is routed through ffmpeg instead of being streamed as-is - see
|
||||
:func:`_extract_audio`.
|
||||
"""
|
||||
temp_path = folder / f".downloading-{filename}.tmp"
|
||||
try:
|
||||
if episode.enclosure_type.startswith("video/"):
|
||||
await _extract_audio(episode.enclosure_url, temp_path)
|
||||
else:
|
||||
async with client.stream(
|
||||
"GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with temp_path.open("wb") as handle:
|
||||
async for chunk in response.aiter_bytes():
|
||||
handle.write(chunk)
|
||||
temp_path.replace(folder / filename)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _extract_audio(source_url: str, dest: Path) -> None:
|
||||
"""Pull ``source_url`` (a video enclosure) through ffmpeg, writing just its audio
|
||||
track to ``dest``. ffmpeg fetches the URL itself, so the video is never stored.
|
||||
|
||||
Raises :class:`AudioExtractionError` rather than a bare ``OSError`` or timeout, so
|
||||
callers can tell a missing/failing ffmpeg apart from an ordinary network error -
|
||||
but either way this is meant to be logged and skipped, not fatal.
|
||||
"""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise AudioExtractionError(
|
||||
"ffmpeg is not installed; cannot extract audio from a video enclosure"
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
source_url,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
"-f",
|
||||
"mp3",
|
||||
str(dest),
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, stderr = await asyncio.wait_for(process.communicate(), timeout=_FFMPEG_TIMEOUT)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise AudioExtractionError(f"ffmpeg timed out extracting audio from {source_url}") from None
|
||||
if process.returncode != 0:
|
||||
raise AudioExtractionError(
|
||||
f"ffmpeg exited {process.returncode} extracting audio from {source_url}: "
|
||||
f"{stderr.decode(errors='replace')[-500:]}"
|
||||
)
|
||||
|
||||
|
||||
async def download_episode_cover(
|
||||
client: httpx2.AsyncClient, folder: Path, cover_url: str, filename: str
|
||||
) -> None:
|
||||
"""Best-effort sidecar cover image fetch, atomic like :func:`download_episode`.
|
||||
|
||||
Cover art is small enough that streaming it in chunks isn't worth the extra code.
|
||||
"""
|
||||
temp_path = folder / f".downloading-{filename}.tmp"
|
||||
try:
|
||||
response = await client.get(cover_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
temp_path.write_bytes(response.content)
|
||||
temp_path.replace(folder / filename)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _extract_og_image(html: str) -> str | None:
|
||||
match = _OG_IMAGE_RE.search(html)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1) or match.group(2)
|
||||
|
||||
|
||||
async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) -> str | None:
|
||||
"""The best cover URL available for ``episode``, or ``None`` when there isn't one.
|
||||
|
||||
Prefers the feed's own per-episode image. Failing that, when the feed links to a
|
||||
page of the episode's own (as Wissen macht Ah does, despite having no per-item
|
||||
image tag), falls back to that page's ``og:image`` - one targeted fetch of a URL
|
||||
the feed itself provided, not a scrape of a list page. Never raises: an
|
||||
unreachable page, a redirect loop, or a page with no such tag all just mean no
|
||||
cover was found here.
|
||||
"""
|
||||
if episode.cover_url:
|
||||
return episode.cover_url
|
||||
if not episode.link:
|
||||
return None
|
||||
try:
|
||||
response = await client.get(episode.link, timeout=_OG_IMAGE_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
except httpx2.HTTPError as exc:
|
||||
_log.debug("Could not fetch %s for its og:image: %s", episode.link, exc)
|
||||
return None
|
||||
return _extract_og_image(response.text)
|
||||
|
||||
|
||||
def _has_sidecar_cover(audio_path: Path) -> bool:
|
||||
return any(audio_path.with_suffix(ext).is_file() for ext in _IMAGE_EXTENSIONS)
|
||||
|
||||
|
||||
async def _sync_covers(
|
||||
client: httpx2.AsyncClient, folder: Path, episodes: list[Episode], now: datetime
|
||||
) -> bool:
|
||||
"""Fetch the sidecar cover of every episode in ``episodes`` that is on disk without
|
||||
one. Returns whether any cover was added.
|
||||
|
||||
Covers every episode on disk, not just the ones this pass downloaded: episodes
|
||||
placed by hand, or downloaded before a cover route existed (Wissen macht Ah's
|
||||
``og:image`` fallback), would otherwise never get theirs. An episode whose cover
|
||||
can't be found or fetched is remembered and left alone for :data:`_RETRY_BACKOFF`,
|
||||
like a failed download, so a feed with no per-episode art doesn't cost one page
|
||||
fetch per episode on every poll.
|
||||
"""
|
||||
missing = _load_failed(folder, _MISSING_COVERS_FILENAME)
|
||||
on_disk: set[str] = set()
|
||||
added = False
|
||||
for episode in episodes:
|
||||
filename = episode_filename(
|
||||
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||
)
|
||||
audio_path = folder / filename
|
||||
if not audio_path.is_file():
|
||||
continue
|
||||
on_disk.add(filename)
|
||||
if _has_sidecar_cover(audio_path):
|
||||
missing.pop(filename, None)
|
||||
continue
|
||||
last_miss = missing.get(filename)
|
||||
if last_miss is not None and now - last_miss < _RETRY_BACKOFF:
|
||||
continue
|
||||
|
||||
cover_url = await resolve_episode_cover(client, episode)
|
||||
if cover_url is None:
|
||||
missing[filename] = now
|
||||
continue
|
||||
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
||||
try:
|
||||
await download_episode_cover(client, folder, cover_url, cover_filename)
|
||||
except (httpx2.HTTPError, OSError) as exc:
|
||||
_log.warning(
|
||||
"Could not download cover for episode %r in %s: %s",
|
||||
episode.title,
|
||||
folder.name,
|
||||
exc,
|
||||
)
|
||||
missing[filename] = now
|
||||
continue
|
||||
missing.pop(filename, None)
|
||||
added = True
|
||||
|
||||
missing = {filename: when for filename, when in missing.items() if filename in on_disk}
|
||||
_save_failed(folder, _MISSING_COVERS_FILENAME, missing)
|
||||
return added
|
||||
|
||||
|
||||
async def sync_show(
|
||||
client: httpx2.AsyncClient,
|
||||
folder: Path,
|
||||
feed_url: str,
|
||||
*,
|
||||
keep: int | None = DEFAULT_EPISODE_LIMIT,
|
||||
) -> bool:
|
||||
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
|
||||
|
||||
Downloads what is missing from that window and deletes what has fallen out of it.
|
||||
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||
|
||||
Returns whether anything changed, deletions and backfilled covers included: a
|
||||
pruned folder needs a rescan just as much as a downloaded episode does, or the
|
||||
library keeps offering tracks whose files are gone.
|
||||
|
||||
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
||||
logged and swallowed here so one bad show never stops the others or takes down the
|
||||
poll loop. An episode that fails is remembered and left alone for
|
||||
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
|
||||
doesn't get hammered on every poll.
|
||||
"""
|
||||
try:
|
||||
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
except httpx2.HTTPError as exc:
|
||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||
return False
|
||||
|
||||
window = newest_episodes(parse_feed(response.content), keep)
|
||||
pending = missing_episodes(folder, window)
|
||||
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
|
||||
now = datetime.now(UTC)
|
||||
changed = False
|
||||
for episode, filename in pending:
|
||||
last_failure = failed.get(filename)
|
||||
if last_failure is not None and now - last_failure < _RETRY_BACKOFF:
|
||||
continue
|
||||
try:
|
||||
await download_episode(client, folder, episode, filename)
|
||||
except (httpx2.HTTPError, OSError, AudioExtractionError) as exc:
|
||||
_log.warning(
|
||||
"Could not download episode %r for %s: %s", episode.title, folder.name, exc
|
||||
)
|
||||
failed[filename] = now
|
||||
continue
|
||||
failed.pop(filename, None)
|
||||
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
||||
changed = True
|
||||
|
||||
pending_filenames = {filename for _episode, filename in pending}
|
||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||
_save_failed(folder, _FAILED_DOWNLOADS_FILENAME, failed)
|
||||
|
||||
if await _sync_covers(client, folder, window, now):
|
||||
changed = True
|
||||
|
||||
# After downloading, not before: an episode that just arrived is one of the newest
|
||||
# and must be counted when deciding what falls off the end.
|
||||
if prune_show(folder, keep):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
async def sync_all_shows(
|
||||
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
|
||||
) -> bool:
|
||||
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||
changed = False
|
||||
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||
if await sync_show(client, folder, feed_url, keep=keep):
|
||||
changed = True
|
||||
return changed
|
||||
381
python-backend/musicmouse/library/scanner.py
Normal file
381
python-backend/musicmouse/library/scanner.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects.
|
||||
|
||||
One level under each section folder, every directory holding audio files is one album.
|
||||
Tags come from mutagen; where a section's tags are known to be useless the folder name
|
||||
wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad
|
||||
input - an unreadable file loses its metadata, not the boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||
from musicmouse.library.colors import colors_from_cover, colors_from_id
|
||||
from musicmouse.library.models import Album, LibraryTrack, album_id
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind, Section
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["scan_library"]
|
||||
|
||||
#: Checked in order for a cover sitting next to the audio.
|
||||
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
|
||||
|
||||
#: Extensions tried for an episode's own same-stem sidecar cover image (see
|
||||
#: :func:`_cover_for_episode`), matching what `podcast_feeds.py`'s
|
||||
#: ``episode_cover_filename`` can produce.
|
||||
_SIDECAR_COVER_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
|
||||
|
||||
#: How often a long scan reports where it is, so a library of thousands of albums
|
||||
#: doesn't sit silent for minutes with nothing on the console to say it is still going.
|
||||
_PROGRESS_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _album_folders(folder: Path, extensions: frozenset[str]) -> Iterator[Path]:
|
||||
"""Every leaf album folder under ``folder``, however deep it is nested.
|
||||
|
||||
A folder that holds audio files directly *is* an album - an artist who groups
|
||||
their books under an extra "ab 3" / "ab 5" age-range folder, or a series folder
|
||||
that groups its episodes one directory further down than usual, still bottoms out
|
||||
here without needing to be special-cased. Only descended into when a folder holds
|
||||
no audio of its own, so an ordinary album folder is never mistaken for a grouping
|
||||
folder just because it also happens to contain subfolders.
|
||||
"""
|
||||
if _audio_files(folder, extensions):
|
||||
yield folder
|
||||
return
|
||||
for sub in sorted(folder.iterdir(), key=lambda path: path.name):
|
||||
if sub.is_dir() and not sub.name.startswith("."):
|
||||
yield from _album_folders(sub, extensions)
|
||||
|
||||
|
||||
def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]:
|
||||
"""The playable files in ``folder``, alphabetically.
|
||||
|
||||
Dotfiles are skipped outright and everything else must match ``audio_extensions``,
|
||||
which is what keeps a podcast folder's ``archive.json`` and its half-finished
|
||||
``.podcast-dl-*.download.tmp`` out of the playlist.
|
||||
"""
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file()
|
||||
and not path.name.startswith(".")
|
||||
and path.suffix.lower() in extensions
|
||||
),
|
||||
key=lambda path: path.name,
|
||||
)
|
||||
|
||||
|
||||
def _tags(path: Path) -> tuple[dict[str, str], float]:
|
||||
"""``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read."""
|
||||
try:
|
||||
import mutagen
|
||||
|
||||
audio = mutagen.File(path, easy=True)
|
||||
if audio is None:
|
||||
return {}, 0.0
|
||||
tags = {key: values[0] for key, values in dict(audio).items() if values}
|
||||
return tags, float(getattr(audio.info, "length", 0.0) or 0.0)
|
||||
except Exception: # pragma: no cover - mutagen raises freely on damaged files
|
||||
_log.debug("No readable tags in %s", path)
|
||||
return {}, 0.0
|
||||
|
||||
|
||||
def _embedded_art(path: Path) -> bytes | None:
|
||||
try:
|
||||
import mutagen
|
||||
|
||||
raw = mutagen.File(path)
|
||||
if raw is None or raw.tags is None:
|
||||
return None
|
||||
for key in raw.tags:
|
||||
if key.startswith("APIC"):
|
||||
data: bytes = raw.tags[key].data
|
||||
return data
|
||||
except Exception: # pragma: no cover - defensive
|
||||
_log.debug("No readable embedded art in %s", path)
|
||||
return None
|
||||
|
||||
|
||||
def _split_folder_name(name: str) -> tuple[str, str]:
|
||||
"""``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``."""
|
||||
artist, separator, title = name.partition(" - ")
|
||||
return (artist, title) if separator else ("", name)
|
||||
|
||||
|
||||
def _most_common(values: list[str]) -> str:
|
||||
"""The tag value most files in an album agree on, ignoring blanks."""
|
||||
counted = Counter(value for value in values if value)
|
||||
return counted.most_common(1)[0][0] if counted else ""
|
||||
|
||||
|
||||
def _cover_for(
|
||||
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
||||
) -> tuple[Path | None, bytes | None]:
|
||||
"""The album's art, as a path *into the cache* plus the original bytes.
|
||||
|
||||
Every branch goes through :meth:`LibraryCache.store_cover`, including the one that
|
||||
finds a ``cover.jpg`` already sitting in the folder. Returning that file directly
|
||||
would be the obvious thing and was the original behaviour, and it quietly undid the
|
||||
downscaling: a folder cover is whatever the internet gave it, often 1920px or more,
|
||||
and the browser then decoded all of it to fill a 185px card. The bytes come back
|
||||
undownscaled either way, because `colors_from_cover` wants the real art.
|
||||
"""
|
||||
for name in _COVER_NAMES:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
art = candidate.read_bytes()
|
||||
return cache.store_cover(identifier, art), art
|
||||
for path in paths[:3]:
|
||||
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||
embedded = _embedded_art(path)
|
||||
if embedded is not None:
|
||||
return cache.store_cover(identifier, embedded), embedded
|
||||
return None, None
|
||||
|
||||
|
||||
def _cover_for_episode(
|
||||
folder: Path, path: Path, identifier: str, cache: LibraryCache
|
||||
) -> tuple[Path | None, bytes | None]:
|
||||
"""The reverse priority from :func:`_cover_for`: with one album per episode, art
|
||||
specific to this one episode wins over the folder's shared cover. A same-stem
|
||||
sidecar image (what ``podcast_feeds.py`` saves when a feed has real per-episode
|
||||
art) is tried first - it's a plain file stat, cheaper than reading tags - then the
|
||||
episode's own embedded art, then the folder's shared cover as the last resort for
|
||||
an episode with neither."""
|
||||
for extension in _SIDECAR_COVER_EXTENSIONS:
|
||||
candidate = path.with_suffix(extension)
|
||||
if candidate.is_file():
|
||||
art = candidate.read_bytes()
|
||||
return cache.store_cover(identifier, art), art
|
||||
embedded = _embedded_art(path)
|
||||
if embedded is not None:
|
||||
return cache.store_cover(identifier, embedded), embedded
|
||||
for name in _COVER_NAMES:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
shared = candidate.read_bytes()
|
||||
return cache.store_cover(identifier, shared), shared
|
||||
return None, None
|
||||
|
||||
|
||||
def scan_album(
|
||||
folder: Path,
|
||||
*,
|
||||
root: Path,
|
||||
section_name: str,
|
||||
section: Section,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> tuple[Album, Fingerprint] | None:
|
||||
paths = _audio_files(folder, extensions)
|
||||
if not paths:
|
||||
_log.debug("No audio files in %s", folder)
|
||||
return None
|
||||
if section.order == "newest_first":
|
||||
paths.reverse()
|
||||
|
||||
fingerprint = Fingerprint.of(paths)
|
||||
identifier = album_id(root, folder)
|
||||
|
||||
tracks: list[LibraryTrack] = []
|
||||
albums: list[str] = []
|
||||
artists: list[str] = []
|
||||
for path in paths:
|
||||
tags, duration = _tags(path)
|
||||
tracks.append(
|
||||
LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration)
|
||||
)
|
||||
albums.append(tags.get("album", ""))
|
||||
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
||||
|
||||
# A figure with several albums keeps them in subfolders of its own folder, so the
|
||||
# figure is the folder directly under the section, however deep the album sits.
|
||||
figure = folder.relative_to(root / section_name).parts[0] if section.figures else None
|
||||
# Every other shelf is named after what is on it. A figure folder is named after the
|
||||
# figurine, so its media type has to be declared in the config.
|
||||
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
||||
|
||||
folder_artist, folder_title = _split_folder_name(folder.name)
|
||||
|
||||
if section.title_from == "folder":
|
||||
title = folder.name
|
||||
if figure is not None:
|
||||
# A figure folder is named in lowercase ("fuchs"); it sits next to real
|
||||
# album titles in the browse grid, so give it a capital.
|
||||
title = figure[:1].upper() + figure[1:]
|
||||
if folder.name != figure:
|
||||
title = f"{title} - {folder.name}"
|
||||
else:
|
||||
title = _most_common(albums) or folder_title or folder.name
|
||||
|
||||
if section.artist_from == "folder":
|
||||
artist = folder.name
|
||||
else:
|
||||
artist = _most_common(artists) or folder_artist
|
||||
|
||||
cover, art = _cover_for(folder, paths, identifier, cache)
|
||||
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
|
||||
|
||||
album = Album(
|
||||
id=identifier,
|
||||
section=section_name,
|
||||
kind=kind,
|
||||
title=title,
|
||||
artist=artist,
|
||||
# Books group by who or what they are about; music groups by artist. The browse
|
||||
# view's category row is built straight off this, so it takes only the part
|
||||
# before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus
|
||||
# Osterwalder, ..." is a credit list whose first name is the character.
|
||||
series=artist.split(",")[0].strip() if kind == "book" else None,
|
||||
figure=figure,
|
||||
colors=colors,
|
||||
folder=folder,
|
||||
cover=cover,
|
||||
tracks=tuple(tracks),
|
||||
)
|
||||
return album, fingerprint
|
||||
|
||||
|
||||
def scan_episodes(
|
||||
folder: Path,
|
||||
*,
|
||||
root: Path,
|
||||
section_name: str,
|
||||
section: Section,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
known: dict[str, tuple[Album, Fingerprint]],
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""One album per audio file, for a section whose folder is a show rather than a
|
||||
single release - a podcast feed's hundreds of episodes, most obviously.
|
||||
|
||||
Fingerprinted per file rather than per folder, so a new episode landing in an
|
||||
already-scanned show only costs scanning that one file, not the whole show.
|
||||
"""
|
||||
paths = _audio_files(folder, extensions)
|
||||
if section.order == "newest_first":
|
||||
paths.reverse()
|
||||
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for path in paths:
|
||||
identifier = album_id(root, path)
|
||||
# The sidecar cover counts too: one fetched after the episode was first scanned
|
||||
# (``podcast_feeds.py`` backfills them) must invalidate the cached entry, or the
|
||||
# episode keeps showing the show's shared cover.
|
||||
sidecars = [path.with_suffix(ext) for ext in _SIDECAR_COVER_EXTENSIONS]
|
||||
fingerprint = Fingerprint.of([path, *(p for p in sidecars if p.is_file())])
|
||||
cached = known.get(identifier)
|
||||
if cached is not None and cached[1] == fingerprint:
|
||||
out[identifier] = cached
|
||||
continue
|
||||
|
||||
tags, duration = _tags(path)
|
||||
title = tags.get("title") or path.stem
|
||||
cover, art = _cover_for_episode(folder, path, identifier, cache)
|
||||
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
|
||||
|
||||
album = Album(
|
||||
id=identifier,
|
||||
section=section_name,
|
||||
kind=section.kind,
|
||||
title=title,
|
||||
artist=folder.name,
|
||||
# The folder is the show; every episode in it groups under the same series,
|
||||
# exactly like an audiobook's chapters group under its book.
|
||||
series=folder.name,
|
||||
figure=None,
|
||||
colors=colors,
|
||||
folder=folder,
|
||||
cover=cover,
|
||||
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
|
||||
)
|
||||
out[identifier] = (album, fingerprint)
|
||||
return out
|
||||
|
||||
|
||||
def scan_library(
|
||||
root: Path,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
*,
|
||||
known: dict[str, tuple[Album, Fingerprint]] | None = None,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""Scan every section under ``root``.
|
||||
|
||||
``known`` is the previously cached index: a folder whose files, sizes and mtimes are
|
||||
unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a
|
||||
figure name to what it holds, which is the one thing the folders cannot say.
|
||||
"""
|
||||
cache.prepare()
|
||||
known = known or {}
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
last_report = time.monotonic()
|
||||
|
||||
for section_name, section in SECTIONS.items():
|
||||
section_root = root / section_name
|
||||
if not section_root.is_dir():
|
||||
_log.warning("Library section %r has no folder at %s", section_name, section_root)
|
||||
continue
|
||||
|
||||
for top in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||
if not top.is_dir() or top.name.startswith("."):
|
||||
continue
|
||||
|
||||
if section.album_unit == "episode":
|
||||
# Fingerprinted per episode inside scan_episodes; the whole-folder
|
||||
# shortcut below does not apply since one folder yields many albums,
|
||||
# and an episode show is never nested deeper than this either.
|
||||
out.update(
|
||||
scan_episodes(
|
||||
top,
|
||||
root=root,
|
||||
section_name=section_name,
|
||||
section=section,
|
||||
extensions=extensions,
|
||||
cache=cache,
|
||||
known=known,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
for folder in _album_folders(top, extensions):
|
||||
now = time.monotonic()
|
||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||
_log.info(
|
||||
"Scanning library: %d albums found so far, now in %s",
|
||||
len(out),
|
||||
folder.relative_to(root),
|
||||
)
|
||||
last_report = now
|
||||
|
||||
identifier = album_id(root, folder)
|
||||
cached = known.get(identifier)
|
||||
if cached is not None:
|
||||
paths = _audio_files(folder, extensions)
|
||||
if paths and Fingerprint.of(paths) == cached[1]:
|
||||
out[identifier] = cached
|
||||
continue
|
||||
scanned = scan_album(
|
||||
folder,
|
||||
root=root,
|
||||
section_name=section_name,
|
||||
section=section,
|
||||
extensions=extensions,
|
||||
cache=cache,
|
||||
figure_kinds=figure_kinds,
|
||||
)
|
||||
if scanned is not None:
|
||||
out[identifier] = scanned
|
||||
|
||||
_log.info("Library: %d albums under %s", len(out), root)
|
||||
return out
|
||||
52
python-backend/musicmouse/library/sections.py
Normal file
52
python-backend/musicmouse/library/sections.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""The four shelves of the music library, and how each one differs.
|
||||
|
||||
These are folder names, not configuration. The library has one root and the layout
|
||||
underneath it is fixed - a section that needed configuring would be a section whose
|
||||
quirks are not understood yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||
|
||||
AlbumKind: TypeAlias = Literal["music", "book"]
|
||||
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
|
||||
TitleSource: TypeAlias = Literal["tags", "folder"]
|
||||
ArtistSource: TypeAlias = Literal["tags", "folder"]
|
||||
AlbumUnit: TypeAlias = Literal["folder", "episode"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Section:
|
||||
kind: AlbumKind = "music"
|
||||
#: Subfolders are figure folders: their name is the figure name from the config.
|
||||
figures: bool = False
|
||||
order: TrackOrder = "filename"
|
||||
title_from: TitleSource = "tags"
|
||||
artist_from: ArtistSource = "tags"
|
||||
#: "folder" (default): one album per folder, every audio file a track/chapter of it.
|
||||
#: "episode": one album per audio *file* - a show folder groups its episodes rather
|
||||
#: than being one giant album itself. `title_from`/`artist_from` are not consulted
|
||||
#: for this unit: an episode's title always comes from its own tags (or filename),
|
||||
#: and the folder always supplies the artist/series, so episodes of the same show
|
||||
#: still group together everywhere the browse view groups by category.
|
||||
album_unit: AlbumUnit = "folder"
|
||||
|
||||
|
||||
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
|
||||
#: presenter list ("Thomas Welling, Sarah Schultes, ...") and its ``album`` tag is the
|
||||
#: feed name, so neither groups usefully - the folder name is the show. And its files
|
||||
#: are named ``YYYYMMDD - Title.mp3``, so reversing filename order puts the newest
|
||||
#: episode first, which is the one anybody wants.
|
||||
SECTIONS: Final[dict[str, Section]] = {
|
||||
# A figure folder is named after the figure, and its contents are whatever that
|
||||
# figure should play - often several albums' worth. The folder name is the honest
|
||||
# title; the tags still supply a useful artist.
|
||||
"Figuren": Section(figures=True, title_from="folder"),
|
||||
"Musik": Section(),
|
||||
"Hörbücher": Section(kind="book"),
|
||||
"Kinderpodcasts": Section(kind="book", order="newest_first", album_unit="episode"),
|
||||
}
|
||||
143
python-backend/musicmouse/library/workers.py
Normal file
143
python-backend/musicmouse/library/workers.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
|
||||
|
||||
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
|
||||
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
|
||||
long C calls that release it. So a library-sized pass parallelizes across *processes*:
|
||||
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
|
||||
executor whose workers are separate interpreters, and a 12-core desktop chews through a
|
||||
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
|
||||
has to stay polite on.
|
||||
|
||||
Polite means three things, all of them set here rather than at the call site:
|
||||
|
||||
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
|
||||
web server never have to fight a full house of analyzers for a timeslice.
|
||||
* **Workers run niced**, so even the cores they do own yield to playback instantly.
|
||||
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
|
||||
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
|
||||
slower than N, not faster.
|
||||
|
||||
Whatever an analyzer returns has to survive the trip back from a worker process, which
|
||||
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
|
||||
frozen dataclasses of floats. An analyzer that records state in its own instance -
|
||||
a test double counting calls, say - only sees that state in the worker, so such an
|
||||
analyzer must be run with ``workers=1``, where everything stays in this process on a
|
||||
thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import Executor, ProcessPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
|
||||
|
||||
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
|
||||
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
|
||||
#: big machine the limit that bites first is RAM, not cores. An explicit
|
||||
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
|
||||
_MAX_AUTO_WORKERS: Final = 8
|
||||
|
||||
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
|
||||
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
|
||||
#: harmless.
|
||||
_NICENESS: Final = 5
|
||||
|
||||
#: Forced into every worker *before* it imports numpy, which reads these once at import
|
||||
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
|
||||
#: machine and the pool oversubscribes every core several times over.
|
||||
_SINGLE_THREADED: Final = {
|
||||
"OMP_NUM_THREADS": "1",
|
||||
"OPENBLAS_NUM_THREADS": "1",
|
||||
"MKL_NUM_THREADS": "1",
|
||||
"NUMEXPR_NUM_THREADS": "1",
|
||||
"NUMBA_NUM_THREADS": "1",
|
||||
}
|
||||
|
||||
|
||||
def default_worker_count() -> int:
|
||||
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
|
||||
|
||||
The core left over is for the rest of the app: audio decoding, the web server and
|
||||
the serial link all have to stay responsive while a first-time pass runs for hours.
|
||||
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
|
||||
path rather than a pool of one.
|
||||
"""
|
||||
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
|
||||
|
||||
|
||||
def analyze_one(
|
||||
analyzer: Analyzer, path: Path
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
"""Analyze one file off the event loop - in a worker process, or on a thread here.
|
||||
|
||||
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
|
||||
the calling *thread*, so on the in-process path this makes idle-time analysis yield
|
||||
CPU without touching threads doing other work; in a worker process there is nothing
|
||||
else in the process to slow down anyway.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
os.nice(_NICENESS)
|
||||
return analyzer.analyze(path)
|
||||
|
||||
|
||||
def _init_worker() -> None:
|
||||
"""Runs once per worker process, before it imports librosa or numpy.
|
||||
|
||||
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
|
||||
unpickles its first task, so setting the thread-count variables here still lands
|
||||
ahead of numpy reading them.
|
||||
"""
|
||||
os.environ.update(_SINGLE_THREADED)
|
||||
with contextlib.suppress(OSError):
|
||||
os.nice(_NICENESS)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def analysis_pool(workers: int) -> Iterator[Executor | None]:
|
||||
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
|
||||
|
||||
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
|
||||
at all - means the caller should run each track on a thread instead. That path is
|
||||
what the tests and single-core devices use, and it is the only one where an
|
||||
analyzer holding state in its own instance behaves as written.
|
||||
|
||||
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
|
||||
reader and libVLC's own threads running, and forking that is a well-known way to
|
||||
inherit a held lock and deadlock in a child. The price is one librosa import per
|
||||
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
|
||||
first-time pass over a real library costs.
|
||||
"""
|
||||
if workers <= 1:
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
executor = ProcessPoolExecutor(
|
||||
max_workers=workers,
|
||||
mp_context=multiprocessing.get_context("spawn"),
|
||||
initializer=_init_worker,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
_log.warning(
|
||||
"Could not start %d analysis workers; analyzing in this process instead",
|
||||
workers,
|
||||
exc_info=True,
|
||||
)
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
yield executor
|
||||
finally:
|
||||
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
|
||||
# waiting there would hold it up for however long the tracks in flight take.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
46
python-backend/musicmouse/media.py
Normal file
46
python-backend/musicmouse/media.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Playlist model.
|
||||
|
||||
What the player plays: a name and the files, in order. The browse API serves
|
||||
:class:`~musicmouse.library.models.Album` instead, which carries the tag metadata;
|
||||
``Album.to_playlist()`` builds one of these from it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["Playlist", "Track"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Track:
|
||||
path: Path
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.path.stem
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Track({self.title!r})"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Playlist:
|
||||
name: str
|
||||
tracks: tuple[Track, ...]
|
||||
#: Set when this playlist came from a library album. It is how a front-end answers
|
||||
#: "what is playing?" without keeping its own copy of that state.
|
||||
album_id: str | None = None
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tracks)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.tracks)
|
||||
|
||||
def __getitem__(self, index: int) -> Track:
|
||||
return self.tracks[index]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Playlist({self.name!r}, {len(self.tracks)} tracks)"
|
||||
22
python-backend/musicmouse/reactions/__init__.py
Normal file
22
python-backend/musicmouse/reactions/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Reactions: the policy layer.
|
||||
|
||||
Every behaviour the mouse has lives here as a small function registered against an
|
||||
event. Nothing else in the codebase decides what should happen - the devices only
|
||||
report and obey, and the services only translate.
|
||||
|
||||
Importing this package is what makes the reactions exist; ``register_all`` binds them
|
||||
to a bus and an :class:`~musicmouse.app.App`.
|
||||
|
||||
Publishing button presses to Home Assistant has no reaction of its own: the MQTT
|
||||
service subscribes to those events directly.
|
||||
"""
|
||||
|
||||
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
||||
lighting,
|
||||
playback,
|
||||
selection,
|
||||
status,
|
||||
)
|
||||
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
||||
|
||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||
183
python-backend/musicmouse/reactions/lighting.py
Normal file
183
python-backend/musicmouse/reactions/lighting.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""What the LEDs do.
|
||||
|
||||
These write straight to the device rather than going through intents: unlike
|
||||
play/pause, nothing else in the system asks for "the figure's start animation".
|
||||
|
||||
Figure animations drive the shelf strip as well as the ring, which means they compete
|
||||
with Home Assistant for it. That is intentional - the device is the single writer and
|
||||
the most recent effect wins, whichever side it came from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.color import ColorRGBW, parse_color
|
||||
from musicmouse.config import FigureColors
|
||||
from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectStaticConfig,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
PlaybackChanged,
|
||||
PlaylistFinished,
|
||||
TouchButtonPressed,
|
||||
TouchButtonReleased,
|
||||
)
|
||||
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
|
||||
from musicmouse.reactions.registry import on
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
|
||||
|
||||
#: The mouse strip starts 6 LEDs into its 45, so its swipe is offset to match the ring.
|
||||
MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360
|
||||
MOUSE_BELL_CURVE_WIDTH = 16
|
||||
SWIPE_SPEED = 180
|
||||
|
||||
#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in
|
||||
#: the album's own primary colour. Deliberately unlike the figure swipe - the strips
|
||||
#: should say *which* way the mouse was started, not just that it was.
|
||||
WEB_CIRCLE_WIDTH = 270.0 # degrees
|
||||
WEB_CIRCLE_SPEED = 12.0 # degrees per second
|
||||
|
||||
|
||||
@on(ActiveFigureChanged)
|
||||
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||
if event.figure is None:
|
||||
off_animation(app)
|
||||
else:
|
||||
# A figure with several albums gets the album picker on the ring instead
|
||||
# (see ``reactions.selection``), which fades into this ring effect when done.
|
||||
ring = len(app.playlists.get(event.figure, ())) < 2
|
||||
start_animation(app, app.colors(event.figure), ring=ring)
|
||||
app.mouse.set_button_brightness(
|
||||
app.config.general.button_leds_brightness, origin="device"
|
||||
)
|
||||
|
||||
|
||||
@on(PlaybackChanged)
|
||||
def web_playback(event: PlaybackChanged, app: App) -> None:
|
||||
"""Light the strips for playback that no figure started.
|
||||
|
||||
Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop
|
||||
and playlist-end in one place. The guard is what keeps this off the figure path:
|
||||
while a figure is on the reader its animation owns all three zones.
|
||||
"""
|
||||
if app.mouse.active_figure is not None:
|
||||
return
|
||||
if not event.playing:
|
||||
off_animation(app)
|
||||
return
|
||||
|
||||
album = app.album_for(event.playlist)
|
||||
if album is None:
|
||||
return
|
||||
web_animation(app, parse_color(album.colors[0]))
|
||||
|
||||
|
||||
@on(PlaylistFinished)
|
||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||
off_animation(app)
|
||||
|
||||
|
||||
@on(TouchButtonPressed)
|
||||
def touch_pressed(event: TouchButtonPressed, app: App) -> None:
|
||||
colors = _active_colors(app)
|
||||
if colors is None:
|
||||
return
|
||||
app.mouse.set_effect(
|
||||
LedZone.MOUSE, _range_effect(event.button, colors.accent), origin="device"
|
||||
)
|
||||
|
||||
|
||||
@on(TouchButtonReleased)
|
||||
def touch_released(event: TouchButtonReleased, app: App) -> None:
|
||||
colors = _active_colors(app)
|
||||
# Clear the touched area first, then restore the whole-body effect over it.
|
||||
app.mouse.set_effect(
|
||||
LedZone.MOUSE,
|
||||
_range_effect(event.button, colors.primary if colors else OFF_COLOR),
|
||||
origin="device",
|
||||
)
|
||||
if colors is None:
|
||||
return
|
||||
fade_to_figure(app, LedZone.MOUSE, colors)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- animations
|
||||
|
||||
|
||||
def start_animation(app: App, colors: FigureColors, *, ring: bool = True) -> None:
|
||||
swipe = EffectSwipeAndChange()
|
||||
swipe.swipe.primary_color = colors.primary
|
||||
swipe.swipe.secondary_color = colors.secondary
|
||||
swipe.swipe.swipe_speed = SWIPE_SPEED
|
||||
swipe.change.color1 = colors.primary
|
||||
swipe.change.color2 = colors.secondary
|
||||
|
||||
if ring:
|
||||
app.mouse.set_effect(LedZone.RING, swipe, origin="device")
|
||||
app.mouse.set_effect(LedZone.SHELF, deepcopy(swipe), origin="device")
|
||||
|
||||
mouse = deepcopy(swipe)
|
||||
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
|
||||
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
|
||||
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
||||
|
||||
|
||||
def fade_to_figure(app: App, zone: LedZone, colors: FigureColors) -> None:
|
||||
"""Fade ``zone`` from whatever it shows into the figure's resting colours - where
|
||||
the start animation ends up after its swipe."""
|
||||
app.mouse.set_effect(
|
||||
zone,
|
||||
EffectRandomTwoColorInterpolationConfig(
|
||||
color1=colors.primary, color2=colors.secondary, start_with_existing=True
|
||||
),
|
||||
origin="device",
|
||||
)
|
||||
|
||||
|
||||
def web_animation(app: App, color: ColorRGBW) -> None:
|
||||
for zone in LedZone:
|
||||
app.mouse.set_effect(
|
||||
zone,
|
||||
EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color),
|
||||
origin="device",
|
||||
)
|
||||
app.mouse.set_button_brightness(app.config.general.button_leds_brightness, origin="device")
|
||||
|
||||
|
||||
def off_animation(app: App) -> None:
|
||||
_log.info("Running off animation")
|
||||
app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device")
|
||||
app.mouse.set_effect(LedZone.SHELF, EffectReverseSwipe(), origin="device")
|
||||
app.mouse.set_effect(
|
||||
LedZone.MOUSE,
|
||||
EffectReverseSwipe(start_position=MOUSE_SWIPE_START_DEGREES),
|
||||
origin="device",
|
||||
)
|
||||
app.mouse.set_button_brightness(0.0, origin="device")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _active_colors(app: App) -> FigureColors | None:
|
||||
"""The current figure's colours, or ``None`` if nothing is playing."""
|
||||
figure = app.mouse.active_figure
|
||||
if figure is None or not app.player.is_playing:
|
||||
return None
|
||||
return app.colors(figure)
|
||||
|
||||
|
||||
def _range_effect(button: TouchButton, color: ColorRGBW) -> EffectStaticConfig:
|
||||
begin, end = MOUSE_LED_RANGES[button]
|
||||
return EffectStaticConfig(color, begin, end)
|
||||
221
python-backend/musicmouse/reactions/playback.py
Normal file
221
python-backend/musicmouse/reactions/playback.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""What the mouse plays, and when.
|
||||
|
||||
Physical inputs are turned into *intents*, and the intents are what actually drive the
|
||||
player. That indirection is the point: an MQTT command, a web request or an IR remote key
|
||||
emits the same intent and lands in the same handler, so there is one place per behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
ButtonEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayFigureRequested,
|
||||
PlaylistFinished,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.reactions.registry import on
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ figure on/off
|
||||
|
||||
|
||||
@on(ActiveFigureChanged)
|
||||
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||
if event.figure is None:
|
||||
_figure_removed(event.previous, app)
|
||||
else:
|
||||
app.bus.emit(
|
||||
PlayFigureRequested(
|
||||
figure=event.figure,
|
||||
restart=app.state.last_partially_played_figure != event.figure,
|
||||
source="device",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _figure_removed(previous: str | None, app: App) -> None:
|
||||
if app.player.is_playing:
|
||||
app.player.pause()
|
||||
# Remember where we were, so putting the same figure back resumes.
|
||||
app.state.last_partially_played_figure = previous
|
||||
_log.info("Figure %r removed mid-playlist", previous)
|
||||
else:
|
||||
app.state.last_partially_played_figure = None
|
||||
|
||||
|
||||
@on(PlayFigureRequested)
|
||||
def play_figure(event: PlayFigureRequested, app: App) -> None:
|
||||
"""Play the figure's album - the one it played last, if it has several.
|
||||
|
||||
Another figure placed in between forgets only *where* in that album it was
|
||||
(``restart``), not which album it was.
|
||||
"""
|
||||
choices = app.choices(event.figure)
|
||||
if not choices:
|
||||
return
|
||||
playlist = choices[app.default_choice(event.figure)]
|
||||
remember_figure_album(app, event.figure, playlist)
|
||||
|
||||
# Equality, not identity: a library rescan (the refresh button, or the podcast
|
||||
# poller landing an episode) rebuilds every Playlist object, and an `is` check made
|
||||
# the figure start over after that. Equal means the same album with the same files;
|
||||
# if the files changed, starting over is the honest thing to do.
|
||||
if not event.restart and app.player.playlist == playlist:
|
||||
_log.info("Resuming %r", event.figure)
|
||||
app.player.play()
|
||||
return
|
||||
|
||||
_log.info("Starting %r (%s) from the beginning", event.figure, playlist.album_id)
|
||||
app.player.set_playlist(playlist)
|
||||
app.player.play_from_start()
|
||||
|
||||
|
||||
def remember_figure_album(app: App, figure: str, playlist: Playlist) -> None:
|
||||
"""Make ``playlist`` the album ``figure`` picks next time it is put down."""
|
||||
if playlist.album_id is not None:
|
||||
app.state.figure_albums[figure] = playlist.album_id
|
||||
|
||||
|
||||
def _start_album(app: App, album: Album, track_index: int) -> None:
|
||||
# A figure album started from anywhere becomes the album that figure picks next.
|
||||
playlist = None
|
||||
if album.figure is not None:
|
||||
playlist = next(
|
||||
(p for p in app.playlists.get(album.figure, ()) if p.album_id == album.id), None
|
||||
)
|
||||
if playlist is not None:
|
||||
remember_figure_album(app, album.figure, playlist)
|
||||
app.player.set_playlist(playlist or album.to_playlist())
|
||||
app.player.play_track(track_index)
|
||||
|
||||
|
||||
@on(PlayAlbumRequested)
|
||||
def play_album(event: PlayAlbumRequested, app: App) -> None:
|
||||
"""Play any album in the library. This is the web front-end's way in."""
|
||||
album = app.library.get(event.album_id)
|
||||
if album is None:
|
||||
_log.warning("No album %r in the library", event.album_id)
|
||||
return
|
||||
_start_album(app, album, event.track_index)
|
||||
|
||||
|
||||
@on(PlaySeriesLatestRequested)
|
||||
def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
||||
"""Play the newest episode of a podcast show - the IR remote's number-key way in."""
|
||||
album = app.library.latest_episode(event.series)
|
||||
if album is None:
|
||||
_log.warning("No episodes for series %r", event.series)
|
||||
return
|
||||
_start_album(app, album, 0)
|
||||
|
||||
|
||||
@on(PlayRemoteSlotRequested)
|
||||
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
|
||||
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
|
||||
|
||||
A slot naming one song starts the album there; the rest of the album follows, as
|
||||
for any other start mid-album.
|
||||
"""
|
||||
slot = app.config.remote.get(event.digit)
|
||||
if slot is None:
|
||||
_log.debug("Number key %s is not assigned", event.digit)
|
||||
return
|
||||
if slot.target_kind == "series":
|
||||
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
|
||||
return
|
||||
album = app.library.get(slot.target)
|
||||
if album is None:
|
||||
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
|
||||
return
|
||||
_start_album(app, album, slot.track_index)
|
||||
|
||||
|
||||
@on(PlaylistFinished)
|
||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||
# Nothing was left half-played, so the next placement starts from the top.
|
||||
app.state.last_partially_played_figure = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- physical inputs
|
||||
|
||||
|
||||
@on(ButtonEvent)
|
||||
def button_pressed(event: ButtonEvent, app: App) -> None:
|
||||
if event.action is not ButtonAction.PRESSED:
|
||||
return
|
||||
if event.button is Button.LEFT and app.player.is_playing:
|
||||
app.bus.emit(PreviousTrackRequested(source="device"))
|
||||
elif event.button is Button.RIGHT and app.player.is_playing:
|
||||
app.bus.emit(NextTrackRequested(source="device"))
|
||||
# The rotary press is published to Home Assistant by the MQTT service; what it
|
||||
# controls is an automation over there, not something this backend decides. While
|
||||
# the ring's album picker is open the press is the picker's instead, and does not
|
||||
# arrive here at all (see ``MusicMouseDevice.capture_knob``).
|
||||
|
||||
|
||||
@on(RotaryTurned)
|
||||
def rotary_turned(event: RotaryTurned, app: App) -> None:
|
||||
step = app.config.general.volume_increment * abs(event.increment)
|
||||
if event.direction is RotaryDirection.UP:
|
||||
app.bus.emit(VolumeChangeRequested(delta=step, source="device"))
|
||||
elif event.direction is RotaryDirection.DOWN:
|
||||
app.bus.emit(VolumeChangeRequested(delta=-step, source="device"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- intents
|
||||
|
||||
|
||||
@on(NextTrackRequested)
|
||||
def next_track(event: NextTrackRequested, app: App) -> None:
|
||||
_log.debug("Next track (%s)", event.source)
|
||||
app.player.next_track()
|
||||
|
||||
|
||||
@on(PreviousTrackRequested)
|
||||
def previous_track(event: PreviousTrackRequested, app: App) -> None:
|
||||
_log.debug("Previous track (%s)", event.source)
|
||||
app.player.previous_track()
|
||||
|
||||
|
||||
@on(PlayRequested)
|
||||
def play(_event: PlayRequested, app: App) -> None:
|
||||
app.player.play()
|
||||
|
||||
|
||||
@on(PauseRequested)
|
||||
def pause(_event: PauseRequested, app: App) -> None:
|
||||
app.player.pause()
|
||||
|
||||
|
||||
@on(SeekRequested)
|
||||
def seek(event: SeekRequested, app: App) -> None:
|
||||
app.player.seek(event.position)
|
||||
|
||||
|
||||
@on(VolumeChangeRequested)
|
||||
def change_volume(event: VolumeChangeRequested, app: App) -> None:
|
||||
app.player.change_volume(event.delta, source=event.source)
|
||||
|
||||
|
||||
@on(SetVolumeRequested)
|
||||
def set_volume(event: SetVolumeRequested, app: App) -> None:
|
||||
app.player.set_volume(event.volume, source=event.source)
|
||||
57
python-backend/musicmouse/reactions/registry.py
Normal file
57
python-backend/musicmouse/reactions/registry.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""The ``@on`` decorator and the binding step.
|
||||
|
||||
Kept in its own module so the reaction modules can import ``on`` without importing the
|
||||
package that imports them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.events import Event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmouse.app import App
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||
|
||||
E = TypeVar("E", bound=Event)
|
||||
|
||||
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
|
||||
|
||||
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
|
||||
|
||||
|
||||
def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
|
||||
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
||||
|
||||
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
||||
_REGISTRY.append((event_type, reaction))
|
||||
return reaction
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def registered() -> list[tuple[type[Event], Reaction[Any]]]:
|
||||
return list(_REGISTRY)
|
||||
|
||||
|
||||
def register_all(bus: EventBus, app: App) -> None:
|
||||
"""Subscribe every declared reaction, with ``app`` bound as its second argument."""
|
||||
for event_type, reaction in _REGISTRY:
|
||||
bus.subscribe(event_type, _bind(reaction, app))
|
||||
_log.debug("Registered %d reactions", len(_REGISTRY))
|
||||
|
||||
|
||||
def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
|
||||
def handler(event: E) -> Any:
|
||||
return reaction(event, app)
|
||||
|
||||
# Keep the reaction's name, so a failing handler is identifiable in the log.
|
||||
handler.__qualname__ = getattr(reaction, "__qualname__", repr(reaction))
|
||||
return handler
|
||||
235
python-backend/musicmouse/reactions/selection.py
Normal file
235
python-backend/musicmouse/reactions/selection.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Picking one of a figure's albums on the LED ring.
|
||||
|
||||
A figure whose folder holds several albums starts playing its default one at once, as
|
||||
any figure does, and meanwhile offers all of them on the ring: one coloured section
|
||||
each, the selected one bright and wobbling. The knob moves the selection one section
|
||||
per detent, and pressing it plays the selected album. Left alone, the picker closes by
|
||||
itself and whatever is playing carries on.
|
||||
|
||||
While it is open the picker has the knob to itself (``MusicMouseDevice.capture_knob``):
|
||||
turning does not change the volume and pressing is not sent to Home Assistant.
|
||||
|
||||
The firmware only draws the ring. Which section is selected, when to give up, and what
|
||||
to play are decided here, and the ring is simply sent again on every detent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from musicmouse.app import App, FigureSelector
|
||||
from musicmouse.blubb import confirm_wav, tick_wav
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.effects import EffectSegmentSelect
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
FigureSelectionChanged,
|
||||
FigureSelectorExpired,
|
||||
PlayAlbumRequested,
|
||||
PlaylistFinished,
|
||||
SelectorPressed,
|
||||
SelectorTurned,
|
||||
)
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.reactions.lighting import fade_to_figure
|
||||
from musicmouse.reactions.registry import on
|
||||
from musicmouse.sound import play_wav_soon, ui_level
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
#: One colour per album, by position. Neighbours are far apart in hue, so the sections
|
||||
#: stay easy to tell apart even with the edge blend shifting them towards each other.
|
||||
PALETTE: tuple[ColorRGBW, ...] = (
|
||||
ColorRGBW(1.0, 0.0, 0.0, 0.0), # red
|
||||
ColorRGBW(1.0, 0.35, 0.0, 0.0), # orange
|
||||
ColorRGBW(1.0, 0.8, 0.0, 0.0), # yellow
|
||||
ColorRGBW(0.0, 0.9, 0.1, 0.0), # green
|
||||
ColorRGBW(0.0, 0.3, 1.0, 0.0), # blue
|
||||
ColorRGBW(0.6, 0.0, 1.0, 0.0), # violet
|
||||
)
|
||||
|
||||
#: Turning faster than this plays one tick, not a pile of overlapping ones.
|
||||
TICK_INTERVAL = 0.06
|
||||
#: Peak of each sound at full UI loudness, as a fraction of full scale.
|
||||
TICK_GAIN = 0.25
|
||||
CONFIRM_GAIN = 0.3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tuning:
|
||||
"""How the picker looks and behaves. Field names match
|
||||
:class:`~musicmouse.config.FigureSelectionConfig`, whose values override these."""
|
||||
|
||||
timeout: float = 8.0 # seconds without a turn before the picker closes
|
||||
#: Where the first section starts. The figure's swipe starts at LED 0 and meets
|
||||
#: itself opposite, which is the front of the ring.
|
||||
offset_degrees: float = 180.0
|
||||
ring_direction: int = 1
|
||||
knob_direction: int = 1
|
||||
gap_leds: int = 1
|
||||
dim_factor: float = 0.15
|
||||
wobble_amplitude: float = 1.5 # LEDs
|
||||
wobble_period_ms: float = 1000.0
|
||||
slide_ms: float = 200.0
|
||||
edge_blend: float = 0.25
|
||||
|
||||
|
||||
DEFAULTS = Tuning()
|
||||
|
||||
|
||||
def tuning(app: App) -> Tuning:
|
||||
""":data:`DEFAULTS`, with whatever the config's ``figure_selection`` overrides."""
|
||||
overrides = app.config.general.figure_selection.model_dump(exclude_none=True)
|
||||
return replace(DEFAULTS, **overrides)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- reactions
|
||||
|
||||
|
||||
@on(ActiveFigureChanged)
|
||||
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||
# Whatever replaces the figure - nothing, or another one - sets the ring itself.
|
||||
if app.state.selector is not None:
|
||||
_close(app, fade=False)
|
||||
if event.figure is None:
|
||||
return
|
||||
choices = app.playlists.get(event.figure, ())
|
||||
if len(choices) > 1:
|
||||
_open(app, event.figure, choices)
|
||||
|
||||
|
||||
@on(SelectorTurned)
|
||||
def selector_turned(event: SelectorTurned, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return # a turn still in the queue when the picker closed
|
||||
settings = tuning(app)
|
||||
now = app.clock.now()
|
||||
selector.deadline = now + settings.timeout
|
||||
|
||||
previous = selector.selected
|
||||
selector.selected = (previous + event.steps * settings.knob_direction) % len(selector.choices)
|
||||
if selector.selected == previous:
|
||||
return
|
||||
app.mouse.set_effect(LedZone.RING, _ring(app, selector, previous), origin="device")
|
||||
if now - selector.last_tick >= TICK_INTERVAL:
|
||||
selector.last_tick = now
|
||||
_play_sound(app, tick_wav, TICK_GAIN)
|
||||
_announce(app, selector, open_=True)
|
||||
|
||||
|
||||
@on(SelectorPressed)
|
||||
def selector_pressed(_event: SelectorPressed, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return
|
||||
playlist = selector.choices[selector.selected]
|
||||
_close(app, fade=True)
|
||||
_play_sound(app, confirm_wav, CONFIRM_GAIN)
|
||||
# By value: a rescan rebuilds the Playlist objects (see play_figure).
|
||||
if app.player.playlist == playlist:
|
||||
_log.info("Album picker: keeping %s", playlist.album_id)
|
||||
return
|
||||
_log.info("Album picker: %r -> %s", selector.figure, playlist.album_id)
|
||||
if playlist.album_id is not None:
|
||||
# The same way in as the web front-end, which also remembers the album as the
|
||||
# figure's for next time.
|
||||
app.bus.emit(PlayAlbumRequested(album_id=playlist.album_id, source="device"))
|
||||
|
||||
|
||||
@on(FigureSelectorExpired)
|
||||
def selector_expired(event: FigureSelectorExpired, app: App) -> None:
|
||||
selector = app.state.selector
|
||||
# Stale if the picker it was for has closed, or a newer one is still in time.
|
||||
if selector is None or selector.figure != event.figure:
|
||||
return
|
||||
if app.clock.now() < selector.deadline:
|
||||
return
|
||||
_close(app, fade=True)
|
||||
|
||||
|
||||
@on(PlaylistFinished)
|
||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||
# The off animation takes the ring; the knob goes back to being a volume knob.
|
||||
if app.state.selector is not None:
|
||||
_close(app, fade=False)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- internals
|
||||
|
||||
|
||||
def _open(app: App, figure: str, choices: tuple[Playlist, ...]) -> None:
|
||||
selector = FigureSelector(
|
||||
figure=figure,
|
||||
choices=choices,
|
||||
selected=app.default_choice(figure),
|
||||
deadline=app.clock.now() + tuning(app).timeout,
|
||||
)
|
||||
app.state.selector = selector
|
||||
app.mouse.capture_knob(True)
|
||||
app.mouse.set_effect(LedZone.RING, _ring(app, selector, None), origin="device")
|
||||
selector.expiry = asyncio.create_task(_expire(app, selector), name="album-picker")
|
||||
_log.info("Album picker for %r: %d albums", figure, len(choices))
|
||||
_announce(app, selector, open_=True)
|
||||
|
||||
|
||||
def _close(app: App, *, fade: bool) -> None:
|
||||
selector = app.state.selector
|
||||
if selector is None:
|
||||
return
|
||||
app.state.selector = None
|
||||
app.mouse.capture_knob(False)
|
||||
if selector.expiry is not None:
|
||||
selector.expiry.cancel()
|
||||
if fade:
|
||||
fade_to_figure(app, LedZone.RING, app.colors(selector.figure))
|
||||
_announce(app, selector, open_=False)
|
||||
|
||||
|
||||
async def _expire(app: App, selector: FigureSelector) -> None:
|
||||
# Every turn pushes the deadline back, so sleep until it stops moving.
|
||||
while (remaining := selector.deadline - app.clock.now()) > 0:
|
||||
await app.clock.sleep(remaining)
|
||||
app.bus.emit(FigureSelectorExpired(figure=selector.figure))
|
||||
|
||||
|
||||
def _ring(app: App, selector: FigureSelector, previous: int | None) -> EffectSegmentSelect:
|
||||
settings = tuning(app)
|
||||
return EffectSegmentSelect(
|
||||
colors=PALETTE[: len(selector.choices)],
|
||||
selected=selector.selected,
|
||||
previous=previous,
|
||||
gap_leds=settings.gap_leds,
|
||||
offset_degrees=settings.offset_degrees,
|
||||
direction=settings.ring_direction,
|
||||
dim_factor=settings.dim_factor,
|
||||
wobble_amplitude=settings.wobble_amplitude,
|
||||
wobble_period_ms=settings.wobble_period_ms,
|
||||
slide_ms=settings.slide_ms,
|
||||
edge_blend=settings.edge_blend,
|
||||
)
|
||||
|
||||
|
||||
def _announce(app: App, selector: FigureSelector, *, open_: bool) -> None:
|
||||
app.bus.emit(
|
||||
FigureSelectionChanged(
|
||||
figure=selector.figure,
|
||||
selected=selector.selected,
|
||||
count=len(selector.choices),
|
||||
open=open_,
|
||||
source="device",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _play_sound(app: App, make: Callable[..., bytes], base_gain: float) -> None:
|
||||
general = app.config.general
|
||||
if general.audio_simulated:
|
||||
return
|
||||
gain = base_gain * ui_level(app.player.volume / 100, general.effects_volume / 100)
|
||||
if gain > 0:
|
||||
play_wav_soon(general.alsa_device, make(gain=gain))
|
||||
20
python-backend/musicmouse/reactions/status.py
Normal file
20
python-backend/musicmouse/reactions/status.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Remembering which links are up.
|
||||
|
||||
The firmware's state is readable straight off the transport, but a broker's is not:
|
||||
:class:`~musicmouse.services.mqtt.service.MqttService` announces it and then forgets.
|
||||
A front-end that wants to show a connection dot needs somewhere to read it from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.events import ConnectionChanged
|
||||
from musicmouse.reactions.registry import on
|
||||
|
||||
|
||||
@on(ConnectionChanged)
|
||||
def connection_changed(event: ConnectionChanged, app: App) -> None:
|
||||
if event.target == "mqtt":
|
||||
app.state.mqtt_connected = event.connected
|
||||
elif event.target == "lirc":
|
||||
app.state.lirc_connected = event.connected
|
||||
175
python-backend/musicmouse/school_days.py
Normal file
175
python-backend/musicmouse/school_days.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Whether today is a school day: weekends, public holidays and school holidays.
|
||||
|
||||
The holidays come from the OpenHolidays API (https://openholidaysapi.org), which has
|
||||
both kinds per German state - Bavaria's ``DE-BY`` school holidays include the
|
||||
Buß- und Bettag, which is a school day off but not a public holiday. Public holidays
|
||||
carry their subdivisions, so a city-only one like Augsburg's Friedensfest
|
||||
(``DE-BY-AU``) counts for Augsburg and not for the rest of Bavaria.
|
||||
|
||||
Fetched once a day and kept in a JSON file, so a device that loses its internet keeps
|
||||
knowing the holidays for the rest of the school year.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"API_URL",
|
||||
"Holiday",
|
||||
"HolidayCalendar",
|
||||
"fetch_calendar",
|
||||
"load_calendar",
|
||||
"parse_holidays",
|
||||
"save_calendar",
|
||||
"school_day",
|
||||
]
|
||||
|
||||
API_URL = "https://openholidaysapi.org"
|
||||
|
||||
#: How far back and ahead each fetch reaches. Ahead covers a whole school year from
|
||||
#: any date, so the cache outlives a long outage.
|
||||
_FETCH_BEFORE = timedelta(days=30)
|
||||
_FETCH_AHEAD = timedelta(days=400)
|
||||
|
||||
_WEEKEND = "Wochenende"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Holiday:
|
||||
start: date
|
||||
end: date
|
||||
name: str
|
||||
nationwide: bool
|
||||
subdivisions: tuple[str, ...]
|
||||
|
||||
def covers(self, day: date) -> bool:
|
||||
return self.start <= day <= self.end
|
||||
|
||||
def applies_to(self, subdivision: str) -> bool:
|
||||
"""``DE-BY-AU`` gets ``DE-BY``'s holidays as well as its own; ``DE-BY`` does
|
||||
not get ``DE-BY-AU``'s."""
|
||||
return self.nationwide or any(
|
||||
subdivision == code or subdivision.startswith(code + "-") for code in self.subdivisions
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HolidayCalendar:
|
||||
#: The day it was fetched, to know when to fetch again.
|
||||
fetched: date
|
||||
subdivision: str
|
||||
school: tuple[Holiday, ...]
|
||||
public: tuple[Holiday, ...]
|
||||
|
||||
|
||||
def parse_holidays(raw: list[dict[str, Any]]) -> tuple[Holiday, ...]:
|
||||
"""The API's JSON list, either endpoint. Entries that do not parse are skipped."""
|
||||
holidays = []
|
||||
for entry in raw:
|
||||
try:
|
||||
names = entry.get("name") or []
|
||||
name = next(
|
||||
(n["text"] for n in names if n.get("language") == "DE"),
|
||||
names[0]["text"] if names else "Feiertag",
|
||||
)
|
||||
holidays.append(
|
||||
Holiday(
|
||||
start=date.fromisoformat(entry["startDate"]),
|
||||
end=date.fromisoformat(entry["endDate"]),
|
||||
name=name,
|
||||
nationwide=bool(entry.get("nationwide")),
|
||||
subdivisions=tuple(s["code"] for s in entry.get("subdivisions") or []),
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, IndexError):
|
||||
_log.debug("Skipping a holiday entry that does not parse: %r", entry)
|
||||
return tuple(holidays)
|
||||
|
||||
|
||||
def school_day(day: date, calendar: HolidayCalendar | None) -> tuple[bool | None, str | None]:
|
||||
"""``(is_school_day, why_not)``. ``None`` when there is no calendar to ask -
|
||||
the weekend is still known then, a holiday is not."""
|
||||
if day.weekday() >= 5:
|
||||
return False, _WEEKEND
|
||||
if calendar is None:
|
||||
return None, None
|
||||
for holiday in (*calendar.public, *calendar.school):
|
||||
if holiday.covers(day) and holiday.applies_to(calendar.subdivision):
|
||||
return False, holiday.name
|
||||
return True, None
|
||||
|
||||
|
||||
async def fetch_calendar(
|
||||
client: httpx2.AsyncClient, subdivision: str, today: date
|
||||
) -> HolidayCalendar:
|
||||
"""Both lists for ``subdivision``'s state. Raises on any network or HTTP error."""
|
||||
# The API files school holidays under the state; city-level ones are filtered here.
|
||||
state = "-".join(subdivision.split("-")[:2])
|
||||
params = {
|
||||
"countryIsoCode": state.split("-")[0],
|
||||
"subdivisionCode": state,
|
||||
"languageIsoCode": "DE",
|
||||
"validFrom": (today - _FETCH_BEFORE).isoformat(),
|
||||
"validTo": (today + _FETCH_AHEAD).isoformat(),
|
||||
}
|
||||
lists = []
|
||||
for endpoint in ("SchoolHolidays", "PublicHolidays"):
|
||||
response = await client.get(f"{API_URL}/{endpoint}", params=params)
|
||||
response.raise_for_status()
|
||||
lists.append(parse_holidays(response.json()))
|
||||
school, public = lists
|
||||
return HolidayCalendar(fetched=today, subdivision=subdivision, school=school, public=public)
|
||||
|
||||
|
||||
def save_calendar(path: Path, calendar: HolidayCalendar) -> None:
|
||||
def dump(holidays: tuple[Holiday, ...]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"startDate": h.start.isoformat(),
|
||||
"endDate": h.end.isoformat(),
|
||||
"name": [{"language": "DE", "text": h.name}],
|
||||
"nationwide": h.nationwide,
|
||||
"subdivisions": [{"code": code} for code in h.subdivisions],
|
||||
}
|
||||
for h in holidays
|
||||
]
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"fetched": calendar.fetched.isoformat(),
|
||||
"subdivision": calendar.subdivision,
|
||||
"school": dump(calendar.school),
|
||||
"public": dump(calendar.public),
|
||||
}
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def load_calendar(path: Path, subdivision: str) -> HolidayCalendar | None:
|
||||
"""The cached calendar, or ``None`` if there is none for this subdivision."""
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if payload["subdivision"] != subdivision:
|
||||
return None
|
||||
return HolidayCalendar(
|
||||
fetched=date.fromisoformat(payload["fetched"]),
|
||||
subdivision=subdivision,
|
||||
school=parse_holidays(payload["school"]),
|
||||
public=parse_holidays(payload["public"]),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
_log.warning("Ignoring an unreadable holiday cache at %s", path)
|
||||
return None
|
||||
10
python-backend/musicmouse/services/__init__.py
Normal file
10
python-backend/musicmouse/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Front-ends: things that mirror state outwards and turn requests into intents.
|
||||
|
||||
MQTT (Home Assistant), web (the browser), lirc (the IR remote) and the screen, timer and
|
||||
podcast services each live here; adding another is a file here plus a line in
|
||||
``__main__._build_services`` - devices and reactions do not change.
|
||||
"""
|
||||
|
||||
from musicmouse.services.base import Service
|
||||
|
||||
__all__ = ["Service"]
|
||||
26
python-backend/musicmouse/services/base.py
Normal file
26
python-backend/musicmouse/services/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""What a front-end has to look like.
|
||||
|
||||
A service gets the bus, subscribes to state events to push outward, and emits intents
|
||||
inward. Nothing else in the app knows which services exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
__all__ = ["Publisher", "Service"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Service(Protocol):
|
||||
name: str
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Long-running task. Cancelled on shutdown; may reconnect internally."""
|
||||
...
|
||||
|
||||
|
||||
class Publisher(Protocol):
|
||||
"""How an entity sends something out, without knowing about the connection."""
|
||||
|
||||
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: ...
|
||||
6
python-backend/musicmouse/services/lirc/__init__.py
Normal file
6
python-backend/musicmouse/services/lirc/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""IR remote control, via lircd's TCP socket."""
|
||||
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
from musicmouse.services.lirc.service import LircService
|
||||
|
||||
__all__ = ["LircButtonEvent", "LircService", "parse_line"]
|
||||
46
python-backend/musicmouse/services/lirc/protocol.py
Normal file
46
python-backend/musicmouse/services/lirc/protocol.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""lircd's classic network protocol: one line per button press or repeat.
|
||||
|
||||
A line looks like::
|
||||
|
||||
0000000000001781 00 BTN_1 Hauppauge
|
||||
|
||||
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
|
||||
the first press and increments while the button is held - lircd has no separate
|
||||
key-up event, just repeats stopping.
|
||||
|
||||
irserver (the IRTrans daemon, which serves this protocol on its own port 8765) sends
|
||||
the same shape but lowercases the button and remote names and always reports repeat
|
||||
``00``::
|
||||
|
||||
b7b2000000000000 00 key_play hauppauge
|
||||
|
||||
Button names are therefore uppercased here, so the rest of the code sees lircd's
|
||||
spelling either way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["LircButtonEvent", "parse_line"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LircButtonEvent:
|
||||
code: str
|
||||
repeat: int
|
||||
button: str
|
||||
remote: str
|
||||
|
||||
|
||||
def parse_line(line: str) -> LircButtonEvent | None:
|
||||
"""One broadcast line, or ``None`` if it does not look like one."""
|
||||
parts = line.strip().split()
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
code, repeat_hex, button, remote = parts
|
||||
try:
|
||||
repeat = int(repeat_hex, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return LircButtonEvent(code=code, repeat=repeat, button=button.upper(), remote=remote)
|
||||
168
python-backend/musicmouse/services/lirc/service.py
Normal file
168
python-backend/musicmouse/services/lirc/service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""The IR remote: a TCP client for lircd, translated into the same intents every other
|
||||
front-end emits.
|
||||
|
||||
Connect, read lines until the link drops, wait, repeat - the same reconnect shape as
|
||||
:class:`~musicmouse.devices.serial_link.SerialLink`, over a plain socket instead of a
|
||||
serial port because lircd speaks its classic protocol on a bare TCP connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import Digit, LircConfig
|
||||
from musicmouse.events import (
|
||||
ConnectionChanged,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
ToggleClockRequested,
|
||||
ToggleScreenRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LircService"]
|
||||
|
||||
#: Acted on only at repeat 0 (the first press) - holding the button must not replay it.
|
||||
_TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
"KEY_PLAY": lambda: PlayRequested(source="lirc"),
|
||||
# No separate "stop" concept exists in this player; the remote's stop button just
|
||||
# pauses, like its pause button.
|
||||
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_STOP": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
# Does nothing unless general.screen is configured - see ScreenService.
|
||||
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
|
||||
# "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
|
||||
# irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
|
||||
# config says KEY_EPG.
|
||||
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
|
||||
}
|
||||
|
||||
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
||||
#: encoder (see ``reactions.playback.rotary_turned``).
|
||||
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
|
||||
|
||||
#: irserver reports every line of a held button as repeat ``00``, so a long-ish press
|
||||
#: would act several times. A line of the same button arriving within this many seconds
|
||||
#: of the previous one is counted as a repeat instead. The Hauppauge remote resends
|
||||
#: about every 0.11s while held; lircd numbers repeats itself and is unaffected.
|
||||
_REPEAT_GAP = 0.25
|
||||
|
||||
_DIGITS: dict[str, Digit] = {
|
||||
"BTN_0": "0",
|
||||
"BTN_1": "1",
|
||||
"BTN_2": "2",
|
||||
"BTN_3": "3",
|
||||
"BTN_4": "4",
|
||||
"BTN_5": "5",
|
||||
"BTN_6": "6",
|
||||
"BTN_7": "7",
|
||||
"BTN_8": "8",
|
||||
"BTN_9": "9",
|
||||
}
|
||||
|
||||
|
||||
class LircService:
|
||||
name = "lirc"
|
||||
|
||||
def __init__(self, app: App, config: LircConfig, *, clock: Clock | None = None) -> None:
|
||||
self.app = app
|
||||
self.config = config
|
||||
self._clock = clock or RealClock()
|
||||
#: Volume to restore on the next mute press, remembered the way the frontend's
|
||||
#: own mute toggle does (App.tsx) rather than via any new Player API.
|
||||
self._pre_mute_volume: int | None = None
|
||||
#: The last line acted on, for counting repeats irserver does not count.
|
||||
self._last: LircButtonEvent | None = None
|
||||
self._last_at = 0.0
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
|
||||
while True:
|
||||
try:
|
||||
await self._session()
|
||||
except OSError as exc:
|
||||
_log.warning(
|
||||
"lircd link to %s:%d lost (%s); retrying in %gs",
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
exc,
|
||||
self.config.reconnect_interval,
|
||||
)
|
||||
await self._clock.sleep(self.config.reconnect_interval)
|
||||
|
||||
async def _session(self) -> None:
|
||||
reader, writer = await asyncio.open_connection(self.config.host, self.config.port)
|
||||
_log.info("Connected to lircd at %s:%d", self.config.host, self.config.port)
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=True, source="lirc"))
|
||||
try:
|
||||
while True:
|
||||
raw = await reader.readline()
|
||||
if not raw:
|
||||
return
|
||||
event = parse_line(raw.decode(errors="replace"))
|
||||
if event is None or event.remote.casefold() != self.config.remote_name.casefold():
|
||||
continue
|
||||
self._handle(self._count_repeat(event))
|
||||
finally:
|
||||
writer.close()
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
|
||||
|
||||
def _count_repeat(self, event: LircButtonEvent) -> LircButtonEvent:
|
||||
now = self._clock.now()
|
||||
last, last_at = self._last, self._last_at
|
||||
self._last_at = now
|
||||
if (
|
||||
event.repeat == 0
|
||||
and last is not None
|
||||
and last.button == event.button
|
||||
and now - last_at < _REPEAT_GAP
|
||||
):
|
||||
event = replace(event, repeat=last.repeat + 1)
|
||||
self._last = event
|
||||
return event
|
||||
|
||||
def _handle(self, event: LircButtonEvent) -> None:
|
||||
if event.button in _TRANSPORT:
|
||||
if event.repeat == 0:
|
||||
self.app.bus.emit(_TRANSPORT[event.button]())
|
||||
elif event.button in _VOLUME:
|
||||
step = self.app.config.general.volume_increment * _VOLUME[event.button]
|
||||
self.app.bus.emit(VolumeChangeRequested(delta=step, source="lirc"))
|
||||
elif event.button == "KEY_MUTE":
|
||||
if event.repeat == 0:
|
||||
self._toggle_mute()
|
||||
elif (digit := _DIGITS.get(event.button)) is not None:
|
||||
if event.repeat == 0:
|
||||
self._play_digit(digit)
|
||||
elif event.repeat == 0:
|
||||
# irserver's names do not always match lircd's; this is how to find out
|
||||
# what a new button is called on the device.
|
||||
_log.debug("Unmapped IR button %s", event.button)
|
||||
|
||||
def _toggle_mute(self) -> None:
|
||||
player = self.app.player
|
||||
if player.volume > 0:
|
||||
self._pre_mute_volume = player.volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=0, source="lirc"))
|
||||
else:
|
||||
restore = self._pre_mute_volume or self.app.config.general.initial_volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||
|
||||
def _play_digit(self, digit: Digit) -> None:
|
||||
self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
|
||||
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal file
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Home Assistant integration over MQTT."""
|
||||
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
from musicmouse.services.mqtt.service import MqttService, build_entities
|
||||
|
||||
__all__ = ["Entity", "MqttService", "build_entities"]
|
||||
107
python-backend/musicmouse/services/mqtt/entity.py
Normal file
107
python-backend/musicmouse/services/mqtt/entity.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Shared plumbing for Home-Assistant-discoverable MQTT entities.
|
||||
|
||||
Adding an entity should be about thirty lines: a discovery payload, a state payload,
|
||||
and whatever bus subscriptions keep it current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.services.base import Publisher
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Entity"]
|
||||
|
||||
|
||||
class Entity(ABC):
|
||||
#: Home Assistant MQTT component, e.g. "light", "sensor", "device_automation".
|
||||
component: ClassVar[str]
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, object_id: str, name: str) -> None:
|
||||
self.bus = bus
|
||||
self.config = config
|
||||
self.object_id = object_id
|
||||
self.name = name
|
||||
self._publisher: Publisher | None = None
|
||||
self.subscribe()
|
||||
|
||||
# -------------------------------------------------------------------- topics
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
return f"{self.config.device_id}_{self.object_id}"
|
||||
|
||||
@property
|
||||
def base_topic(self) -> str:
|
||||
return f"{self.config.base_topic}/{self.object_id}"
|
||||
|
||||
@property
|
||||
def state_topic(self) -> str:
|
||||
return f"{self.base_topic}/state"
|
||||
|
||||
@property
|
||||
def command_topic(self) -> str:
|
||||
return f"{self.base_topic}/set"
|
||||
|
||||
@property
|
||||
def discovery_topic(self) -> str:
|
||||
return f"{self.config.discovery_prefix}/{self.component}/{self.unique_id}/config"
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
"""Topics the service should route to :meth:`handle`."""
|
||||
return ()
|
||||
|
||||
# ------------------------------------------------------------------ contract
|
||||
|
||||
@abstractmethod
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
"""The retained config Home Assistant reads to create this entity."""
|
||||
|
||||
def subscribe(self) -> None:
|
||||
"""Register bus handlers. Called once, at construction."""
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
"""React to a command on one of :meth:`command_topics`."""
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
"""Push current state out. Called on connect and whenever state changes."""
|
||||
|
||||
# ------------------------------------------------------------------- runtime
|
||||
|
||||
def attach(self, publisher: Publisher | None) -> None:
|
||||
self._publisher = publisher
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
return self._publisher is not None
|
||||
|
||||
async def publish(self, topic: str, payload: Any, *, retain: bool = False) -> None:
|
||||
"""Send ``payload`` (JSON-encoded unless it is already a string).
|
||||
|
||||
A no-op while the broker is unreachable: state is republished on reconnect.
|
||||
"""
|
||||
if self._publisher is None:
|
||||
return
|
||||
text = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
await self._publisher.publish(topic, text, retain=retain)
|
||||
|
||||
async def announce(self) -> None:
|
||||
"""Publish discovery, then current state."""
|
||||
await self.publish(self.discovery_topic, self.discovery_payload(), retain=True)
|
||||
await self.publish_state()
|
||||
|
||||
def device_block(self) -> dict[str, Any]:
|
||||
"""Ties every entity to one device in Home Assistant's UI."""
|
||||
return {
|
||||
"identifiers": [self.config.device_id],
|
||||
"name": self.config.device_name,
|
||||
"manufacturer": "bauer.tech",
|
||||
"model": "MusicMouse",
|
||||
}
|
||||
250
python-backend/musicmouse/services/mqtt/lights.py
Normal file
250
python-backend/musicmouse/services/mqtt/lights.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Each LED zone as a Home-Assistant-discoverable JSON light.
|
||||
|
||||
Two things changed from the old ``ShelveLightMqtt``:
|
||||
|
||||
* The ``side_*``/``top_*`` effect names are parsed rather than enumerated, so adding a
|
||||
width or an increment is a data change (see :data:`WIDTHS`, :data:`INCREMENTS`).
|
||||
* State is published from :class:`~musicmouse.events.LedEffectChanged` - the device's
|
||||
report of what it actually did - instead of echoing back the command. When a figure
|
||||
animation overrides an MQTT-set colour, Home Assistant now follows along.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
LedEffect,
|
||||
)
|
||||
from musicmouse.events import LedEffectChanged
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LightEntity", "effect_names", "parse_positional_effect"]
|
||||
|
||||
BLACK = ColorRGBW(0, 0, 0, 0)
|
||||
|
||||
#: Effects that are not simply "light this fraction of the strip".
|
||||
BASE_EFFECTS = ("static", "circular", "wipeup", "twocolor", "twocolorrandom")
|
||||
|
||||
#: Fraction of the strip lit by a positional effect.
|
||||
WIDTHS = (0.2, 0.5)
|
||||
#: Light every n-th LED. 1 is solid.
|
||||
INCREMENTS = (1, 4, 8)
|
||||
|
||||
_POSITIONAL = re.compile(r"^(?P<position>side|top)_(?P<width>\d+(?:\.\d+)?)(?:_inc(?P<inc>\d+))?$")
|
||||
|
||||
DEFAULT_TRANSITION_S = 0.3
|
||||
|
||||
|
||||
def effect_names() -> list[str]:
|
||||
"""Every effect name this entity accepts, for the discovery ``effect_list``."""
|
||||
positional = [
|
||||
f"{position}_{width:g}" + ("" if increment == 1 else f"_inc{increment}")
|
||||
for position in ("side", "top")
|
||||
for width in WIDTHS
|
||||
for increment in INCREMENTS
|
||||
]
|
||||
return [*BASE_EFFECTS, *positional]
|
||||
|
||||
|
||||
def parse_positional_effect(name: str) -> tuple[float, float, int] | None:
|
||||
"""``"side_0.2_inc4"`` -> ``(begin, end, increment)``, or ``None`` if not one.
|
||||
|
||||
``side`` lights a band around the far end of the strip and wraps; ``top`` lights a
|
||||
band centred on the middle.
|
||||
"""
|
||||
match = _POSITIONAL.match(name)
|
||||
if match is None:
|
||||
return None
|
||||
width = float(match["width"])
|
||||
increment = int(match["inc"] or 1)
|
||||
if match["position"] == "side":
|
||||
return 1.0 - width / 2, width / 2, increment
|
||||
return 0.5 - width / 2, 0.5 + width / 2, increment
|
||||
|
||||
|
||||
class LightEntity(Entity):
|
||||
component = "light"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
config: MqttConfig,
|
||||
mouse: MusicMouseDevice,
|
||||
zone: LedZone,
|
||||
name: str,
|
||||
) -> None:
|
||||
self.zone = zone
|
||||
self.mouse = mouse
|
||||
self._state: dict[str, Any] = {
|
||||
"state": "OFF",
|
||||
"color": {"r": 255, "g": 255, "b": 255, "w": 0},
|
||||
"color_mode": "rgbw",
|
||||
"brightness": 30,
|
||||
"effect": "static",
|
||||
}
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
super().__init__(bus, config, object_id=f"light_{zone}", name=name)
|
||||
|
||||
# ---------------------------------------------------------------- discovery
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "json",
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"state_topic": self.state_topic,
|
||||
"brightness": True,
|
||||
"color_mode": True,
|
||||
"supported_color_modes": ["rgbw"],
|
||||
"effect": True,
|
||||
"effect_list": effect_names(),
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(LedEffectChanged, self._on_led_changed)
|
||||
|
||||
# ----------------------------------------------------------------- commands
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
try:
|
||||
command = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
_log.warning("Ignoring non-JSON command on %s: %r", topic, payload[:120])
|
||||
return
|
||||
if not isinstance(command, dict):
|
||||
_log.warning("Ignoring command on %s: expected an object, got %r", topic, command)
|
||||
return
|
||||
|
||||
self._remember_previous_color(command)
|
||||
self._state.update(command)
|
||||
self.mouse.set_effect(self.zone, self._build_effect(), origin="mqtt")
|
||||
# No publish here: LedEffectChanged will report what the device actually did.
|
||||
|
||||
def _remember_previous_color(self, command: dict[str, Any]) -> None:
|
||||
"""Two-colour effects interpolate from the colour that was set before."""
|
||||
if "color" not in command:
|
||||
return
|
||||
brightness = command.get("brightness", self._state["brightness"])
|
||||
new_color = _color_from_json(command["color"], brightness)
|
||||
current = _color_from_json(self._state["color"], self._state["brightness"])
|
||||
if new_color != current:
|
||||
self._last_color = current
|
||||
|
||||
def _build_effect(self) -> LedEffect:
|
||||
state = self._state
|
||||
color = _color_from_json(state["color"], state["brightness"])
|
||||
transition_ms = float(state.get("transition", DEFAULT_TRANSITION_S)) * 1000
|
||||
effect = str(state.get("effect", "static"))
|
||||
|
||||
if state["state"] == "OFF":
|
||||
return _static(BLACK, transition_ms)
|
||||
|
||||
if (positional := parse_positional_effect(effect)) is not None:
|
||||
begin, end, increment = positional
|
||||
return EffectStaticDetailedConfig(
|
||||
color,
|
||||
increment=increment,
|
||||
begin=begin,
|
||||
end=end,
|
||||
transition_time_in_ms=transition_ms,
|
||||
)
|
||||
|
||||
match effect:
|
||||
case "static":
|
||||
return _static(color, transition_ms)
|
||||
case "circular":
|
||||
return EffectCircularConfig(speed=180, width=90, color=color)
|
||||
case "wipeup":
|
||||
swipe_and_change = EffectSwipeAndChange()
|
||||
swipe_and_change.swipe.primary_color = self._last_color
|
||||
swipe_and_change.swipe.secondary_color = color
|
||||
swipe_and_change.swipe.bell_curve_width_in_leds = 10
|
||||
swipe_and_change.swipe.transition_width = 30
|
||||
swipe_and_change.swipe.start_position = 0
|
||||
swipe_and_change.swipe.swipe_speed = 260
|
||||
swipe_and_change.change.color1 = color
|
||||
swipe_and_change.change.color2 = self._last_color
|
||||
return swipe_and_change
|
||||
case "twocolor" | "twocolorrandom":
|
||||
random_hues = effect == "twocolorrandom"
|
||||
return EffectRandomTwoColorInterpolationConfig(
|
||||
color1=color,
|
||||
color2=self._last_color,
|
||||
hue1_random=random_hues,
|
||||
hue2_random=random_hues,
|
||||
start_with_existing=True,
|
||||
)
|
||||
case _:
|
||||
_log.warning("Unknown effect %r on %s, turning it off", effect, self.zone)
|
||||
return _static(BLACK, transition_ms)
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
async def _on_led_changed(self, event: LedEffectChanged) -> None:
|
||||
if event.zone is not self.zone:
|
||||
return
|
||||
if event.origin != "mqtt":
|
||||
self._reconcile(event.effect)
|
||||
await self.publish_state()
|
||||
|
||||
def _reconcile(self, effect: LedEffect) -> None:
|
||||
"""Fold an effect this entity did not ask for into the reported state.
|
||||
|
||||
The mapping is lossy - the firmware has richer effects than the HA light
|
||||
schema - so only on/off and a colour are taken. The effect *name* is left
|
||||
alone, since reporting one outside ``effect_list`` would confuse HA.
|
||||
"""
|
||||
color = getattr(effect, "color", None)
|
||||
if isinstance(effect, EffectStaticConfig | EffectStaticDetailedConfig) and color == BLACK:
|
||||
self._state["state"] = "OFF"
|
||||
return
|
||||
|
||||
self._state["state"] = "ON"
|
||||
if isinstance(color, ColorRGBW):
|
||||
self._state["color"] = _color_to_json(color)
|
||||
self._state["brightness"] = 255
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
await self.publish(self.state_topic, self._state)
|
||||
|
||||
|
||||
def _static(color: ColorRGBW, transition_ms: float) -> LedEffect:
|
||||
if transition_ms > 0:
|
||||
return EffectStaticDetailedConfig(color, transition_time_in_ms=transition_ms)
|
||||
return EffectStaticConfig(color)
|
||||
|
||||
|
||||
def _color_from_json(color: dict[str, int], brightness: int = 255) -> ColorRGBW:
|
||||
scale = brightness / 255
|
||||
r, g, b, w = ((color.get(channel, 0) / 255) * scale for channel in "rgbw")
|
||||
return ColorRGBW(r, g, b, w)
|
||||
|
||||
|
||||
def _color_to_json(color: ColorRGBW) -> dict[str, int]:
|
||||
return {
|
||||
"r": round(color.r * 255),
|
||||
"g": round(color.g * 255),
|
||||
"b": round(color.b * 255),
|
||||
"w": round(color.w * 255),
|
||||
}
|
||||
171
python-backend/musicmouse/services/mqtt/player.py
Normal file
171
python-backend/musicmouse/services/mqtt/player.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""The audio player, exposed to Home Assistant.
|
||||
|
||||
Home Assistant has no MQTT ``media_player`` platform, so the player is published as
|
||||
the pieces that do exist: a sensor for what is going on, a number for the volume, and
|
||||
buttons for the transport. Commands come back in as intents, which is the same path
|
||||
the physical buttons take.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlaybackChanged,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
|
||||
|
||||
|
||||
class PlayerSensor(Entity):
|
||||
component = "sensor"
|
||||
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> None:
|
||||
self.mouse = mouse
|
||||
self.player = player
|
||||
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"state_topic": self.state_topic,
|
||||
"json_attributes_topic": f"{self.base_topic}/attributes",
|
||||
"icon": "mdi:music-circle",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def subscribe(self) -> None:
|
||||
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
|
||||
self.bus.subscribe(event_type, self._on_change)
|
||||
|
||||
async def _on_change(self, _event: Event) -> None:
|
||||
await self.publish_state()
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
track = self.player.current_track
|
||||
playlist = self.player.playlist
|
||||
await self.publish(self.state_topic, "playing" if self.player.is_playing else "paused")
|
||||
await self.publish(
|
||||
f"{self.base_topic}/attributes",
|
||||
{
|
||||
"figure": self.mouse.active_figure,
|
||||
"playlist": playlist.name if playlist else None,
|
||||
"track_index": self.player.track_index,
|
||||
"track_count": len(playlist) if playlist else 0,
|
||||
"title": track.title if track else None,
|
||||
"volume": self.player.volume,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class VolumeNumber(Entity):
|
||||
component = "number"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
|
||||
self.player = player
|
||||
self._min = 0
|
||||
self._max = 100
|
||||
super().__init__(bus, config, object_id="volume", name="Music Mouse Volume")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"state_topic": self.state_topic,
|
||||
"min": self._min,
|
||||
"max": self._max,
|
||||
"step": 1,
|
||||
"mode": "slider",
|
||||
"icon": "mdi:volume-high",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(VolumeChanged, self._on_volume)
|
||||
|
||||
async def _on_volume(self, _event: VolumeChanged) -> None:
|
||||
await self.publish_state()
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
try:
|
||||
volume = int(float(payload))
|
||||
except ValueError:
|
||||
_log.warning("Ignoring non-numeric volume on %s: %r", topic, payload[:40])
|
||||
return
|
||||
self.bus.emit(SetVolumeRequested(volume=volume, source="mqtt"))
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
await self.publish(self.state_topic, str(self.player.volume))
|
||||
|
||||
|
||||
class TransportButton(Entity):
|
||||
component = "button"
|
||||
|
||||
#: Button object id -> the intent pressing it emits.
|
||||
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
|
||||
"next": NextTrackRequested,
|
||||
"previous": PreviousTrackRequested,
|
||||
"play": PlayRequested,
|
||||
"pause": PauseRequested,
|
||||
}
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, action: str, name: str) -> None:
|
||||
self.action = action
|
||||
super().__init__(bus, config, object_id=f"button_{action}", name=name)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
# HA publishes "PRESS"; the payload carries no information beyond "it happened".
|
||||
_log.debug("Transport button %s pressed via %s (%r)", self.action, topic, payload[:20])
|
||||
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
|
||||
|
||||
|
||||
def player_entities(
|
||||
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> list[Entity]:
|
||||
names = {
|
||||
"next": "Music Mouse Next",
|
||||
"previous": "Music Mouse Previous",
|
||||
"play": "Music Mouse Play",
|
||||
"pause": "Music Mouse Pause",
|
||||
}
|
||||
return [
|
||||
PlayerSensor(bus, config, mouse, player),
|
||||
VolumeNumber(bus, config, player),
|
||||
*(TransportButton(bus, config, action, name) for action, name in names.items()),
|
||||
]
|
||||
134
python-backend/musicmouse/services/mqtt/service.py
Normal file
134
python-backend/musicmouse/services/mqtt/service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""The MQTT connection: reconnect, discovery, and routing commands to entities.
|
||||
|
||||
Unlike the old ``start_mqtt``, entities outlive a dropped connection - they keep their
|
||||
state and simply republish it - and a clean restart is not delayed by a reconnect
|
||||
sleep it never needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import ConnectionChanged
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
from musicmouse.services.mqtt.lights import LightEntity
|
||||
from musicmouse.services.mqtt.player import player_entities
|
||||
from musicmouse.services.mqtt.triggers import trigger_entities
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["MqttService", "build_entities"]
|
||||
|
||||
ZONE_NAMES = {
|
||||
LedZone.SHELF: "Music Mouse Regal Licht",
|
||||
LedZone.RING: "Music Mouse Ring",
|
||||
LedZone.MOUSE: "Music Mouse Body",
|
||||
}
|
||||
|
||||
|
||||
def build_entities(
|
||||
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> list[Entity]:
|
||||
"""Everything this backend exposes to Home Assistant."""
|
||||
return [
|
||||
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
|
||||
*player_entities(bus, config, mouse, player),
|
||||
*trigger_entities(bus, config),
|
||||
]
|
||||
|
||||
|
||||
class MqttService:
|
||||
name = "mqtt"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
config: MqttConfig,
|
||||
entities: Iterable[Entity],
|
||||
*,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.config = config
|
||||
self.entities = list(entities)
|
||||
self._clock = clock or RealClock()
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._routes: dict[str, Entity] = {
|
||||
topic: entity for entity in self.entities for topic in entity.command_topics()
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ Publisher
|
||||
|
||||
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
|
||||
client = self._client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
await client.publish(topic, payload.encode(), retain=retain)
|
||||
except aiomqtt.MqttError as exc:
|
||||
_log.debug("Publish to %s failed: %s", topic, exc)
|
||||
|
||||
# -------------------------------------------------------------------- runtime
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._session()
|
||||
except aiomqtt.MqttError as exc:
|
||||
_log.warning(
|
||||
"MQTT connection to %s lost (%s); retrying in %gs",
|
||||
self.config.server,
|
||||
exc,
|
||||
self.config.reconnect_interval,
|
||||
)
|
||||
finally:
|
||||
self._detach()
|
||||
await self._clock.sleep(self.config.reconnect_interval)
|
||||
|
||||
async def _session(self) -> None:
|
||||
async with aiomqtt.Client(
|
||||
hostname=self.config.server,
|
||||
port=self.config.port,
|
||||
username=self.config.user,
|
||||
password=self.config.password,
|
||||
) as client:
|
||||
self._client = client
|
||||
_log.info("Connected to MQTT broker %s:%d", self.config.server, self.config.port)
|
||||
self.bus.emit(ConnectionChanged(target="mqtt", connected=True, source="mqtt"))
|
||||
|
||||
for entity in self.entities:
|
||||
entity.attach(self)
|
||||
await entity.announce()
|
||||
|
||||
await client.subscribe(f"{self.config.base_topic}/#")
|
||||
async for message in client.messages:
|
||||
await self._route(message)
|
||||
|
||||
def _detach(self) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
self._client = None
|
||||
for entity in self.entities:
|
||||
entity.attach(None)
|
||||
self.bus.emit(ConnectionChanged(target="mqtt", connected=False, source="mqtt"))
|
||||
|
||||
async def _route(self, message: aiomqtt.Message) -> None:
|
||||
topic = message.topic.value
|
||||
entity = self._routes.get(topic)
|
||||
if entity is None:
|
||||
return # our own state topics come back on the wildcard subscription
|
||||
payload = message.payload
|
||||
text = payload.decode(errors="replace") if isinstance(payload, bytes) else str(payload)
|
||||
try:
|
||||
await entity.handle(topic, text)
|
||||
except Exception:
|
||||
_log.exception("Entity %s failed on %s", entity.unique_id, topic)
|
||||
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal file
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Button, touch and RFID events, published for Home Assistant to automate on.
|
||||
|
||||
This is what replaces the old direct ``hass-client`` calls. The backend no longer
|
||||
knows that pressing the rotary encoder toggles ``light.kinderzimmer_fluter`` or that
|
||||
the left ear means pink - it reports what happened, and the automation lives in Home
|
||||
Assistant where it can be changed without a deploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.events import ButtonEvent, RfidTokenRead, TouchButtonPressed, TouchButtonReleased
|
||||
from musicmouse.hardware import Button, ButtonAction, TouchButton
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
__all__ = ["ButtonTrigger", "TagScanner", "TouchTrigger", "trigger_entities"]
|
||||
|
||||
#: Which button actions are worth automating on, and the HA trigger type for each.
|
||||
BUTTON_ACTIONS: dict[ButtonAction, str] = {
|
||||
ButtonAction.PRESSED: "button_short_press",
|
||||
ButtonAction.DOUBLE_CLICKED: "button_double_press",
|
||||
ButtonAction.LONG_PRESSED: "button_long_press",
|
||||
}
|
||||
|
||||
|
||||
class _Trigger(Entity):
|
||||
component = "device_automation"
|
||||
|
||||
@property
|
||||
def discovery_topic(self) -> str:
|
||||
# Device triggers are addressed by node id + object id, not by unique id.
|
||||
return (
|
||||
f"{self.config.discovery_prefix}/device_automation/"
|
||||
f"{self.config.device_id}/{self.object_id}/config"
|
||||
)
|
||||
|
||||
@property
|
||||
def trigger_topic(self) -> str:
|
||||
return f"{self.config.base_topic}/trigger/{self.object_id}"
|
||||
|
||||
def _payload(self, trigger_type: str, subtype: str) -> dict[str, Any]:
|
||||
return {
|
||||
"automation_type": "trigger",
|
||||
"topic": self.trigger_topic,
|
||||
"type": trigger_type,
|
||||
"subtype": subtype,
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
|
||||
class ButtonTrigger(_Trigger):
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, button: Button, action: ButtonAction
|
||||
) -> None:
|
||||
self.button = button
|
||||
self.action = action
|
||||
super().__init__(
|
||||
bus,
|
||||
config,
|
||||
object_id=f"{button.slug}_{action.slug}",
|
||||
name=f"{button.slug} {action.slug}",
|
||||
)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return self._payload(BUTTON_ACTIONS[self.action], self.button.slug)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(ButtonEvent, self._on_button)
|
||||
|
||||
async def _on_button(self, event: ButtonEvent) -> None:
|
||||
if event.button is self.button and event.action is self.action:
|
||||
await self.publish(self.trigger_topic, self.action.slug)
|
||||
|
||||
|
||||
class TouchTrigger(_Trigger):
|
||||
TYPES: ClassVar[dict[bool, str]] = {
|
||||
True: "button_short_press",
|
||||
False: "button_short_release",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, button: TouchButton, *, pressed: bool
|
||||
) -> None:
|
||||
self.button = button
|
||||
self.pressed = pressed
|
||||
suffix = "touched" if pressed else "released"
|
||||
super().__init__(
|
||||
bus,
|
||||
config,
|
||||
object_id=f"{button.slug}_{suffix}",
|
||||
name=f"{button.slug} {suffix}",
|
||||
)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return self._payload(self.TYPES[self.pressed], self.button.slug)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
if self.pressed:
|
||||
self.bus.subscribe(TouchButtonPressed, self._on_touch)
|
||||
else:
|
||||
self.bus.subscribe(TouchButtonReleased, self._on_touch)
|
||||
|
||||
async def _on_touch(self, event: TouchButtonPressed | TouchButtonReleased) -> None:
|
||||
if event.button is self.button:
|
||||
await self.publish(self.trigger_topic, self.button.slug)
|
||||
|
||||
|
||||
class TagScanner(Entity):
|
||||
"""The RFID reader, as an HA tag scanner - the natural fit for "a tag was read"."""
|
||||
|
||||
component = "tag"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig) -> None:
|
||||
super().__init__(bus, config, object_id="tag", name="Music Mouse Reader")
|
||||
|
||||
@property
|
||||
def discovery_topic(self) -> str:
|
||||
return f"{self.config.discovery_prefix}/tag/{self.config.device_id}/config"
|
||||
|
||||
@property
|
||||
def scan_topic(self) -> str:
|
||||
return f"{self.config.base_topic}/tag"
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"topic": self.scan_topic,
|
||||
"value_template": "{{ value_json.tag_id }}",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(RfidTokenRead, self._on_tag)
|
||||
|
||||
async def _on_tag(self, event: RfidTokenRead) -> None:
|
||||
await self.publish(
|
||||
self.scan_topic,
|
||||
{"tag_id": event.tag_id.hex(), "figure": event.figure, "known": event.known},
|
||||
)
|
||||
|
||||
|
||||
def trigger_entities(bus: EventBus, config: MqttConfig) -> list[Entity]:
|
||||
return [
|
||||
*(
|
||||
ButtonTrigger(bus, config, button, action)
|
||||
for button in Button
|
||||
for action in BUTTON_ACTIONS
|
||||
),
|
||||
*(
|
||||
TouchTrigger(bus, config, button, pressed=pressed)
|
||||
for button in TouchButton
|
||||
for pressed in (True, False)
|
||||
),
|
||||
TagScanner(bus, config),
|
||||
]
|
||||
63
python-backend/musicmouse/services/podcasts.py
Normal file
63
python-backend/musicmouse/services/podcasts.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Front-end for nothing: the only "intent" this service ever produces is new files on
|
||||
disk. It polls every podcast show that named its feed via a ``feed.txt`` marker (see
|
||||
:mod:`musicmouse.library.podcast_feeds`) and downloads whatever episode is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Final
|
||||
|
||||
import httpx2
|
||||
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["PodcastFeedService"]
|
||||
|
||||
#: Podcasts a children's player subscribes to publish at most a few times a week;
|
||||
#: checking a few times a day is plenty and kind to the feed hosts.
|
||||
_CHECK_INTERVAL_SECONDS: Final = 6 * 3600
|
||||
|
||||
|
||||
class PodcastFeedService:
|
||||
name = "podcast-feeds"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: MusicLibrary,
|
||||
*,
|
||||
client: httpx2.AsyncClient,
|
||||
on_change: Callable[[], Awaitable[None]],
|
||||
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
|
||||
) -> None:
|
||||
self.library = library
|
||||
self.client = client
|
||||
self.on_change = on_change
|
||||
self.interval = interval
|
||||
self.episode_limit = episode_limit
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Checks immediately at startup, then every `interval` seconds.
|
||||
|
||||
A no-op when no show has a ``feed.txt`` - which is the common case, so this can
|
||||
run unconditionally instead of needing its own config toggle. Never lets a
|
||||
single bad pass end the task: `Service.run` is cancelled on shutdown and is
|
||||
otherwise expected to keep going on its own.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
keep = self.episode_limit
|
||||
if await sync_all_shows(self.client, self.library.root, keep=keep):
|
||||
await self.on_change()
|
||||
except Exception:
|
||||
_log.exception("Podcast feed check failed; will retry next interval")
|
||||
await asyncio.sleep(self.interval)
|
||||
finally:
|
||||
await self.client.aclose()
|
||||
204
python-backend/musicmouse/services/screen.py
Normal file
204
python-backend/musicmouse/services/screen.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
|
||||
and switches it off by itself when nothing is going on.
|
||||
|
||||
A service rather than a reaction because the command is a subprocess: the bus awaits
|
||||
async handlers, so running it there would hold every other event up behind ``xset``.
|
||||
Here each press gets its own task, and a hung or failing command is only logged.
|
||||
|
||||
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||
ALSA device, as loud as UI sounds are (:func:`musicmouse.sound.ui_level`). The
|
||||
command's last word of output says which way it went - ``on`` or ``off``, as
|
||||
``kiosk-screen`` prints it; anything else gets no sound.
|
||||
|
||||
With an ``off_command`` it also switches the monitor off by itself once nothing has
|
||||
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
|
||||
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
|
||||
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
|
||||
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
|
||||
for the remote too, not only for the keyboard X already wakes it for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
|
||||
from musicmouse.blubb import blubb_wav
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import ScreenConfig
|
||||
from musicmouse.events import (
|
||||
Event,
|
||||
IntentEvent,
|
||||
LedEffectRequested,
|
||||
PlaybackChanged,
|
||||
ToggleScreenRequested,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["ScreenService"]
|
||||
|
||||
#: The blubb's peak at full player volume, as a fraction of full scale. The music is
|
||||
#: mastered near full scale; a notification sound sits well under it.
|
||||
_BLUBB_GAIN = 0.35
|
||||
|
||||
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
|
||||
#: whether a screen went dark after 15 minutes or 15 and a half.
|
||||
_IDLE_CHECK_INTERVAL = 30.0
|
||||
|
||||
|
||||
class ScreenService:
|
||||
name = "screen"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
config: ScreenConfig,
|
||||
*,
|
||||
alsa_device: str | None,
|
||||
volume: Callable[[], float],
|
||||
is_playing: Callable[[], bool] = lambda: False,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
|
||||
#: very object.
|
||||
self.config = config
|
||||
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||
self.alsa_device = alsa_device
|
||||
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||
#: read at the moment the sound plays.
|
||||
self.volume = volume
|
||||
self.is_playing = is_playing
|
||||
self._clock = clock or RealClock()
|
||||
self._last_activity = self._clock.now()
|
||||
#: The monitor was last switched by the idle auto-off, not by a person.
|
||||
self._auto_off = False
|
||||
self._running: asyncio.Task[None] | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
|
||||
unsubscribe_all = self.bus.subscribe_all(self._on_event)
|
||||
try:
|
||||
while True:
|
||||
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
|
||||
self.check_idle()
|
||||
finally:
|
||||
unsubscribe_toggle()
|
||||
unsubscribe_all()
|
||||
if self._running is not None:
|
||||
self._running.cancel()
|
||||
|
||||
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
|
||||
# Whichever way the press goes, a person decided it; nothing to wake from.
|
||||
self._auto_off = False
|
||||
self._start(self.config.toggle_command, sound=True)
|
||||
|
||||
def _on_event(self, event: Event) -> None:
|
||||
if isinstance(event, PlaybackChanged):
|
||||
self._last_activity = self._clock.now()
|
||||
return
|
||||
# LED intents arrive from Home Assistant automations as well as people, and
|
||||
# "system" intents are the backend talking to itself.
|
||||
if (
|
||||
not isinstance(event, IntentEvent)
|
||||
or isinstance(event, LedEffectRequested)
|
||||
or event.source == "system"
|
||||
):
|
||||
return
|
||||
self._last_activity = self._clock.now()
|
||||
if self._auto_off and not isinstance(event, ToggleScreenRequested):
|
||||
self._auto_off = False
|
||||
if self.config.on_command:
|
||||
self._start(self.config.on_command, sound=True)
|
||||
|
||||
def check_idle(self) -> None:
|
||||
"""Switch the monitor off if it has been idle long enough. Called every
|
||||
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
|
||||
now = self._clock.now()
|
||||
if self.is_playing():
|
||||
# The countdown starts when the music stops, not when it started.
|
||||
self._last_activity = now
|
||||
return
|
||||
minutes = self.config.auto_off_minutes
|
||||
if self._auto_off or not minutes or not self.config.off_command:
|
||||
return
|
||||
if now - self._last_activity < minutes * 60:
|
||||
return
|
||||
if self._start(self.config.off_command, sound=False):
|
||||
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
|
||||
self._auto_off = True
|
||||
|
||||
def _start(self, command: str, *, sound: bool) -> bool:
|
||||
# A second switch while the first command is still running would read the
|
||||
# monitor's state before the first one changed it, and undo it.
|
||||
if self._running is not None and not self._running.done():
|
||||
_log.debug("Screen command still running; ignoring this one")
|
||||
return False
|
||||
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
|
||||
return True
|
||||
|
||||
async def _run(self, command: str, *, sound: bool) -> None:
|
||||
argv = shlex.split(command)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
except OSError as exc:
|
||||
_log.warning("Screen command %r could not start: %s", command, exc)
|
||||
return
|
||||
try:
|
||||
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_log.warning(
|
||||
"Screen command %r took longer than %gs; killed", command, self.config.timeout
|
||||
)
|
||||
return
|
||||
text = output.decode(errors="replace").strip()
|
||||
if process.returncode != 0:
|
||||
_log.warning(
|
||||
"Screen command %r failed (exit %d): %s", command, process.returncode, text
|
||||
)
|
||||
return
|
||||
_log.info("Screen switched%s", f": {text}" if text else "")
|
||||
state = text.split()[-1] if text else ""
|
||||
if sound and state in ("on", "off"):
|
||||
await self.blubb(on=state == "on")
|
||||
|
||||
async def blubb(self, *, on: bool) -> None:
|
||||
gain = _BLUBB_GAIN * max(0.0, min(1.0, self.volume()))
|
||||
if self.alsa_device is None or gain <= 0:
|
||||
return
|
||||
wav = await asyncio.to_thread(blubb_wav, on=on, gain=gain)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"aplay",
|
||||
"-q",
|
||||
"-D",
|
||||
self.alsa_device,
|
||||
"-",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as exc:
|
||||
_log.warning("Could not play the screen sound: %s", exc)
|
||||
return
|
||||
try:
|
||||
_, error = await asyncio.wait_for(process.communicate(wav), self.config.timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_log.warning("Screen sound took longer than %gs; killed", self.config.timeout)
|
||||
return
|
||||
if process.returncode != 0:
|
||||
_log.warning(
|
||||
"Screen sound failed (exit %d): %s",
|
||||
process.returncode,
|
||||
error.decode(errors="replace").strip(),
|
||||
)
|
||||
226
python-backend/musicmouse/services/timers.py
Normal file
226
python-backend/musicmouse/services/timers.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""The clock overlay's backend half: the parents' countdown and today's school day.
|
||||
|
||||
The overlay itself lives in the kiosk browser. What has to live here is what several
|
||||
screens share (a timer set from a parent's phone shows on the kiosk), what has to keep
|
||||
going with the screen off (the chime), and what needs the internet (the holidays).
|
||||
|
||||
The countdown runs on the injected :class:`~musicmouse.clock.Clock` like everything
|
||||
else that waits; wall time comes from ``wall`` so a test can pin the date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from musicmouse.app import AppState, ParentTimer
|
||||
from musicmouse.blubb import chime_wav
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import ClockConfig
|
||||
from musicmouse.events import (
|
||||
CancelTimerRequested,
|
||||
ClockStateChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
LedEffectRequested,
|
||||
SetTimerRequested,
|
||||
)
|
||||
from musicmouse.school_days import (
|
||||
HolidayCalendar,
|
||||
fetch_calendar,
|
||||
load_calendar,
|
||||
save_calendar,
|
||||
school_day,
|
||||
)
|
||||
from musicmouse.sound import play_wav
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["RING_SECONDS", "TimerService", "timer_end"]
|
||||
|
||||
#: How long a timer that ran out keeps showing 00:00 before it clears by itself.
|
||||
RING_SECONDS = 60.0
|
||||
|
||||
#: The chime's peak at full player volume, as a fraction of full scale - soft.
|
||||
_CHIME_GAIN = 0.3
|
||||
|
||||
#: After a failed holiday fetch, try again this much later rather than at midnight.
|
||||
_RETRY_SECONDS = 3600.0
|
||||
#: Wake this long after midnight, so the new day has surely begun.
|
||||
_AFTER_MIDNIGHT = 5.0
|
||||
|
||||
|
||||
def timer_end(now: datetime, *, minutes: int | None = None, until: str | None = None) -> datetime:
|
||||
"""When a timer started at ``now`` ends: ``minutes`` from now, or at the next
|
||||
``until`` ("HH:MM") - tomorrow if that time has already passed today."""
|
||||
if (minutes is None) == (until is None):
|
||||
raise ValueError("give exactly one of minutes and until")
|
||||
if minutes is not None:
|
||||
return now + timedelta(minutes=minutes)
|
||||
assert until is not None
|
||||
hour, minute = (int(part) for part in until.split(":"))
|
||||
end = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
return end if end > now else end + timedelta(days=1)
|
||||
|
||||
|
||||
class TimerService:
|
||||
name = "timers"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
state: AppState,
|
||||
config: ClockConfig,
|
||||
*,
|
||||
cache_file: Path,
|
||||
client: httpx2.AsyncClient,
|
||||
alsa_device: str | None,
|
||||
volume: Callable[[], float],
|
||||
clock: Clock | None = None,
|
||||
wall: Callable[[], datetime] = datetime.now,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.state = state
|
||||
self.config = config
|
||||
self.cache_file = cache_file
|
||||
self.client = client
|
||||
#: Where the chime plays, or ``None`` for no sound (simulated audio).
|
||||
self.alsa_device = alsa_device
|
||||
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||
#: read when the chime plays.
|
||||
self.volume = volume
|
||||
self._clock = clock or RealClock()
|
||||
self._wall = wall
|
||||
self._calendar: HolidayCalendar | None = None
|
||||
self._countdown: asyncio.Task[None] | None = None
|
||||
self._chime_task: asyncio.Task[None] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
async def run(self) -> None:
|
||||
unsubscribe = [
|
||||
self.bus.subscribe(SetTimerRequested, self._on_set),
|
||||
self.bus.subscribe(CancelTimerRequested, self._on_cancel),
|
||||
self.bus.subscribe_all(self._on_event),
|
||||
]
|
||||
try:
|
||||
while True:
|
||||
fetched = await self.refresh_school_day()
|
||||
await self._clock.sleep(self._seconds_to_midnight() if fetched else _RETRY_SECONDS)
|
||||
finally:
|
||||
for undo in unsubscribe:
|
||||
undo()
|
||||
if self._countdown is not None:
|
||||
self._countdown.cancel()
|
||||
await self.client.aclose()
|
||||
|
||||
# ----------------------------------------------------------------- school day
|
||||
|
||||
async def refresh_school_day(self) -> bool:
|
||||
"""Work out today's school day, fetching the holidays if the cached ones are
|
||||
not from today. ``False`` if a fetch was due and failed."""
|
||||
today = self._wall().date()
|
||||
ok = True
|
||||
if self._calendar is None:
|
||||
self._calendar = load_calendar(self.cache_file, self.config.subdivision)
|
||||
if self._calendar is None or self._calendar.fetched < today:
|
||||
try:
|
||||
self._calendar = await fetch_calendar(self.client, self.config.subdivision, today)
|
||||
save_calendar(self.cache_file, self._calendar)
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
_log.warning(
|
||||
"Could not fetch the school holidays (%s); %s",
|
||||
exc,
|
||||
"using the cached ones" if self._calendar else "only weekends are known",
|
||||
)
|
||||
self._set_school_day(today)
|
||||
return ok
|
||||
|
||||
def _set_school_day(self, today: date) -> None:
|
||||
is_school_day, reason = school_day(today, self._calendar)
|
||||
if (is_school_day, reason) == (self.state.school_day, self.state.school_day_reason):
|
||||
return
|
||||
self.state.school_day = is_school_day
|
||||
self.state.school_day_reason = reason
|
||||
_log.info(
|
||||
"Today is %s",
|
||||
"a school day" if is_school_day else f"no school day ({reason or 'unknown'})",
|
||||
)
|
||||
self.bus.emit(ClockStateChanged())
|
||||
|
||||
def _seconds_to_midnight(self) -> float:
|
||||
now = self._wall()
|
||||
midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
|
||||
return (midnight - now).total_seconds() + _AFTER_MIDNIGHT
|
||||
|
||||
# ---------------------------------------------------------------- the timer
|
||||
|
||||
def _on_set(self, event: SetTimerRequested) -> None:
|
||||
self._stop_countdown()
|
||||
now = self._wall().timestamp()
|
||||
self.state.parent_timer = ParentTimer(started_at=now, ends_at=event.ends_at)
|
||||
self.bus.emit(ClockStateChanged())
|
||||
self._countdown = asyncio.create_task(
|
||||
self._run_countdown(event.ends_at - now), name="parent-timer"
|
||||
)
|
||||
|
||||
def _on_cancel(self, _event: CancelTimerRequested) -> None:
|
||||
self._stop_countdown()
|
||||
self._clear()
|
||||
|
||||
def _on_event(self, event: Event) -> None:
|
||||
"""Anything a person does silences a ringing timer - the same notion of "a
|
||||
person" as the screen's idle auto-off: intents, minus Home Assistant's LED
|
||||
writes and the backend talking to itself."""
|
||||
timer = self.state.parent_timer
|
||||
if (
|
||||
timer is None
|
||||
or not timer.ringing
|
||||
or not isinstance(event, IntentEvent)
|
||||
or isinstance(event, (LedEffectRequested, SetTimerRequested, CancelTimerRequested))
|
||||
or event.source == "system"
|
||||
):
|
||||
return
|
||||
self._stop_countdown()
|
||||
self._clear()
|
||||
|
||||
async def _run_countdown(self, seconds: float) -> None:
|
||||
await self._clock.sleep(max(0.0, seconds))
|
||||
timer = self.state.parent_timer
|
||||
if timer is None:
|
||||
return
|
||||
self.state.parent_timer = ParentTimer(
|
||||
started_at=timer.started_at, ends_at=timer.ends_at, ringing=True
|
||||
)
|
||||
_log.info("The parents' timer ran out")
|
||||
self.bus.emit(ClockStateChanged())
|
||||
# Its own task: silencing the timer must not cut the chime off mid-note.
|
||||
self._chime_task = asyncio.create_task(self._chime(), name="parent-timer-chime")
|
||||
await self._clock.sleep(RING_SECONDS)
|
||||
self._countdown = None
|
||||
self._clear()
|
||||
|
||||
async def _chime(self) -> None:
|
||||
if self.alsa_device is None:
|
||||
return
|
||||
gain = _CHIME_GAIN * max(0.0, min(1.0, self.volume()))
|
||||
wav = await asyncio.to_thread(chime_wav, gain=gain)
|
||||
await play_wav(self.alsa_device, wav)
|
||||
|
||||
def _stop_countdown(self) -> None:
|
||||
if self._countdown is not None:
|
||||
self._countdown.cancel()
|
||||
self._countdown = None
|
||||
|
||||
def _clear(self) -> None:
|
||||
if self.state.parent_timer is None:
|
||||
return
|
||||
self.state.parent_timer = None
|
||||
self.bus.emit(ClockStateChanged())
|
||||
6
python-backend/musicmouse/services/web/__init__.py
Normal file
6
python-backend/musicmouse/services/web/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""The web front-end: a browse-and-play UI over the same intents the buttons emit."""
|
||||
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.service import WebService, build_app
|
||||
|
||||
__all__ = ["StateHub", "WebService", "build_app"]
|
||||
404
python-backend/musicmouse/services/web/api.py
Normal file
404
python-backend/musicmouse/services/web/api.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""The HTTP surface.
|
||||
|
||||
Commands are REST and state is a one-way websocket. That split keeps every control path
|
||||
testable with ``curl`` and means a command needs no new machinery: it emits the same
|
||||
intent the MQTT service and the buttons emit, and lands in the same reaction.
|
||||
|
||||
Search is not here on purpose. The whole index goes to the browser once and filtering
|
||||
happens there, which is what makes the design's type-to-search feel instant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
||||
from musicmouse.events import (
|
||||
CancelTimerRequested,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetTimerRequested,
|
||||
SetVolumeRequested,
|
||||
ToggleScreenRequested,
|
||||
UserActivity,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.timers import timer_end
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||
from musicmouse.services.web.schemas import (
|
||||
AlbumOut,
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
RemoteMappingIn,
|
||||
RemoteMappingOut,
|
||||
SeekIn,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
TimerIn,
|
||||
TippenCurriculumOut,
|
||||
TippenProgressOut,
|
||||
TippenRunIn,
|
||||
TippenRunOut,
|
||||
TippenSettingsIn,
|
||||
TippenSettingsOut,
|
||||
TrackCurvesOut,
|
||||
TrackDetailOut,
|
||||
VolumeIn,
|
||||
)
|
||||
from musicmouse.services.web.settings import (
|
||||
read_settings,
|
||||
to_device_volume,
|
||||
write_settings,
|
||||
)
|
||||
from musicmouse.services.web.state import snapshot
|
||||
from musicmouse.services.web.tippen_api import curriculum_out, progress_out, record_tippen_run
|
||||
from musicmouse.tippen.rewards import compute_lock_state
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["build_router"]
|
||||
|
||||
def build_router(
|
||||
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
def emit(intent: IntentEvent) -> Response:
|
||||
app.bus.emit(intent)
|
||||
return Response(status_code=204)
|
||||
|
||||
# ------------------------------------------------------------------- library
|
||||
|
||||
@router.get("/library")
|
||||
def get_library() -> LibraryOut:
|
||||
lock_state = (
|
||||
compute_lock_state(app.tippen.curriculum, app.library, app.tippen.progress)
|
||||
if app.tippen is not None
|
||||
else None
|
||||
)
|
||||
return LibraryOut(
|
||||
albums=[
|
||||
AlbumOut.of(album, lock_state.get(album.id) if lock_state else None)
|
||||
for album in app.library.albums
|
||||
]
|
||||
)
|
||||
|
||||
@router.get("/albums/{album_id}/cover")
|
||||
def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse:
|
||||
album = app.library.get(album_id)
|
||||
if album is None or album.cover is None:
|
||||
# Not an error: the client paints the album's own colours instead.
|
||||
raise HTTPException(status_code=404, detail="no cover")
|
||||
path = album.cover
|
||||
if size == "thumb":
|
||||
thumb = path.with_name(f"{path.stem}.thumb.jpg")
|
||||
if thumb.is_file():
|
||||
path = thumb
|
||||
return FileResponse(
|
||||
path,
|
||||
# A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
|
||||
# exact set of bytes: reprocessing the art changes the library payload, so
|
||||
# the client asks for a new URL. Those can be cached forever, and the
|
||||
# browser then never asks again - no conditional request per card.
|
||||
#
|
||||
# Without `v` the id alone is not enough (the bytes behind it change whenever
|
||||
# the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
|
||||
# decoding old 3000px files out of their cache), so `no-cache` makes the
|
||||
# browser revalidate: it keeps the file and usually gets a 304.
|
||||
headers={
|
||||
"Cache-Control": (
|
||||
"public, max-age=31536000, immutable" if v else "no-cache"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/tracks/{album_id}/{index}/analysis")
|
||||
def get_track_analysis(album_id: str, index: int) -> TrackDetailOut:
|
||||
grid = app.library.beats(album_id, index)
|
||||
curve = app.library.curve(album_id, index)
|
||||
if grid is None and curve is None:
|
||||
raise HTTPException(status_code=404, detail="not analyzed")
|
||||
return TrackDetailOut(
|
||||
times=list(grid.times) if grid else [],
|
||||
strengths=list(grid.strengths) if grid else [],
|
||||
curve=TrackCurvesOut(**curve.to_json()) if curve else None,
|
||||
)
|
||||
|
||||
@router.post("/library/refresh", status_code=202)
|
||||
async def refresh_library() -> Response:
|
||||
# Returns immediately: a cold rescan reads tags from every file.
|
||||
asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006
|
||||
return Response(status_code=202)
|
||||
|
||||
# --------------------------------------------------------------------- state
|
||||
|
||||
@router.get("/state")
|
||||
def get_state() -> PlayerStateOut:
|
||||
return snapshot(app)
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket(socket: WebSocket) -> None:
|
||||
await hub.connect(socket)
|
||||
try:
|
||||
while True:
|
||||
# Push-only. Reading is how we notice the tab closed.
|
||||
await socket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.disconnect(socket)
|
||||
|
||||
# ------------------------------------------------------------------ commands
|
||||
|
||||
@router.post("/play", status_code=204)
|
||||
def play_album(body: PlayIn) -> Response:
|
||||
if app.library.get(body.album_id) is None:
|
||||
raise HTTPException(status_code=404, detail="no such album")
|
||||
return emit(
|
||||
PlayAlbumRequested(
|
||||
album_id=body.album_id, track_index=body.track_index, source="web"
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/resume", status_code=204)
|
||||
def resume() -> Response:
|
||||
return emit(PlayRequested(source="web"))
|
||||
|
||||
@router.post("/pause", status_code=204)
|
||||
def pause() -> Response:
|
||||
return emit(PauseRequested(source="web"))
|
||||
|
||||
@router.post("/next", status_code=204)
|
||||
def next_track() -> Response:
|
||||
return emit(NextTrackRequested(source="web"))
|
||||
|
||||
@router.post("/previous", status_code=204)
|
||||
def previous_track() -> Response:
|
||||
return emit(PreviousTrackRequested(source="web"))
|
||||
|
||||
@router.post("/screen/toggle", status_code=204)
|
||||
def toggle_screen() -> Response:
|
||||
# The device's own monitor - only the kiosk page on the device calls this (its
|
||||
# Del key). Without a screen section in the config nothing listens and it is a
|
||||
# no-op, like the remote's power key.
|
||||
return emit(ToggleScreenRequested(source="web"))
|
||||
|
||||
@router.post("/screen/activity", status_code=204)
|
||||
def screen_activity() -> Response:
|
||||
# The kiosk page on the device reports key presses and taps here, so the
|
||||
# screen's idle auto-off does not blank it under a child who is browsing.
|
||||
return emit(UserActivity(source="web"))
|
||||
|
||||
@router.post("/timer", status_code=204)
|
||||
def set_timer(body: TimerIn) -> Response:
|
||||
# The parents' countdown. The new state, ringing and all, arrives over the
|
||||
# websocket like everything else.
|
||||
end = timer_end(datetime.now(), minutes=body.minutes, until=body.until)
|
||||
return emit(SetTimerRequested(ends_at=end.timestamp(), source="web"))
|
||||
|
||||
@router.delete("/timer", status_code=204)
|
||||
def cancel_timer() -> Response:
|
||||
return emit(CancelTimerRequested(source="web"))
|
||||
|
||||
@router.post("/seek", status_code=204)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
return emit(SeekRequested(position=body.position, source="web"))
|
||||
|
||||
@router.post("/volume", status_code=204)
|
||||
def set_volume(body: VolumeIn) -> Response:
|
||||
general = app.config.general
|
||||
if body.steps is not None:
|
||||
# The IR remote's own step, in device units - see LircService._handle.
|
||||
return emit(
|
||||
VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
|
||||
)
|
||||
if body.percent is None:
|
||||
raise HTTPException(status_code=422, detail="percent or steps required")
|
||||
return emit(
|
||||
SetVolumeRequested(volume=to_device_volume(body.percent, general), source="web")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- parent mode
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings() -> SettingsOut:
|
||||
return read_settings(app.config.general)
|
||||
|
||||
@router.put("/settings")
|
||||
async def put_settings(body: SettingsIn) -> SettingsOut:
|
||||
if body.min_volume > body.max_volume:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="min_volume must not exceed max_volume"
|
||||
)
|
||||
if not body.min_volume <= body.initial_volume <= body.max_volume:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="initial_volume must lie between min and max"
|
||||
)
|
||||
|
||||
general = app.config.general
|
||||
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||
# song to be quieter, not the next boot.
|
||||
values = body.model_dump()
|
||||
auto_off = values.pop("screen_auto_off_minutes")
|
||||
for key, value in values.items():
|
||||
setattr(general, key, value)
|
||||
if auto_off is not None and general.screen is not None:
|
||||
# The same object ScreenService reads on every tick.
|
||||
general.screen.auto_off_minutes = auto_off
|
||||
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
||||
await asyncio.to_thread(write_settings, config_path, body)
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# ----------------------------------------------------------------- tippen
|
||||
|
||||
def _require_tippen() -> TippenRuntime:
|
||||
if app.tippen is None:
|
||||
raise HTTPException(status_code=404, detail="tippen not configured")
|
||||
return app.tippen
|
||||
|
||||
@router.get("/tippen/curriculum")
|
||||
def get_tippen_curriculum() -> TippenCurriculumOut:
|
||||
runtime = _require_tippen()
|
||||
return curriculum_out(runtime.curriculum, app.library)
|
||||
|
||||
@router.get("/tippen/progress")
|
||||
def get_tippen_progress() -> TippenProgressOut:
|
||||
return progress_out(_require_tippen().progress)
|
||||
|
||||
@router.put("/tippen/settings")
|
||||
async def put_tippen_settings(body: TippenSettingsIn) -> TippenSettingsOut:
|
||||
runtime = _require_tippen()
|
||||
runtime.progress = runtime.progress.model_copy(
|
||||
update={"settings": runtime.progress.settings.model_copy(update=body.model_dump())}
|
||||
)
|
||||
await asyncio.to_thread(runtime.save)
|
||||
return TippenSettingsOut(
|
||||
sound=runtime.progress.settings.sound,
|
||||
keyboard_hint=runtime.progress.settings.keyboard_hint,
|
||||
)
|
||||
|
||||
@router.post("/tippen/runs")
|
||||
async def post_tippen_run(body: TippenRunIn) -> TippenRunOut:
|
||||
runtime = _require_tippen()
|
||||
if runtime.curriculum.lessons and not any(
|
||||
lesson.id == body.lesson_id for lesson in runtime.curriculum.lessons
|
||||
):
|
||||
raise HTTPException(status_code=422, detail=f"no such lesson {body.lesson_id!r}")
|
||||
result = await asyncio.to_thread(record_tippen_run, app, body)
|
||||
await hub.broadcast_library()
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------- IR remote
|
||||
|
||||
@router.post("/remote/press/{digit}", status_code=204)
|
||||
def press_remote(digit: Digit) -> Response:
|
||||
# A keyboard's number keys, doing what the IR remote's do. An unassigned key
|
||||
# is not an error - the remote's own press of it is silent too.
|
||||
return emit(PlayRemoteSlotRequested(digit=digit, source="web"))
|
||||
|
||||
@router.get("/remote/mapping")
|
||||
def get_remote_mapping() -> RemoteMappingOut:
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
@router.put("/remote/mapping")
|
||||
async def put_remote_mapping(body: RemoteMappingIn) -> RemoteMappingOut:
|
||||
resolved: dict[Digit, RemoteSlotConfig] = {}
|
||||
for digit, slot in body.slots.items():
|
||||
found = (
|
||||
app.library.get(slot.target)
|
||||
if slot.target_kind == "album"
|
||||
else app.library.latest_episode(slot.target)
|
||||
)
|
||||
if found is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
||||
)
|
||||
if slot.target_kind == "series" and slot.track_index:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"key {digit}: a podcast show has no fixed song to start at",
|
||||
)
|
||||
if slot.track_index >= len(found.tracks):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}",
|
||||
)
|
||||
resolved[digit] = RemoteSlotConfig(
|
||||
target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index
|
||||
)
|
||||
|
||||
app.config.remote = resolved
|
||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
# --------------------------------------------------------------- room control
|
||||
#
|
||||
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||
# to every proxied request below. The browser only gets to know entity ids and
|
||||
# display names (get_ha_config) and can ask this backend to relay a states read or
|
||||
# a service call - the same shape of access the token itself grants, just without
|
||||
# ever leaving the LAN device. Entity ids are not restricted to the configured
|
||||
# list; that would only stop someone who already has enough access to open this
|
||||
# unauthenticated API from asking Home Assistant about a different entity, which
|
||||
# matches the rest of this API's "trusted LAN device" threat model.
|
||||
|
||||
def _require_ha() -> HaConfig:
|
||||
ha = app.config.general.ha
|
||||
if ha is None:
|
||||
raise HTTPException(status_code=404, detail="ha not configured")
|
||||
return ha
|
||||
|
||||
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
|
||||
try:
|
||||
upstream = await ha_client.request(
|
||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||
)
|
||||
except httpx2.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||
) from exc
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
media_type=upstream.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
@router.get("/ha")
|
||||
def get_ha_config() -> HaConfigOut:
|
||||
ha = _require_ha()
|
||||
return HaConfigOut(
|
||||
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
|
||||
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
|
||||
)
|
||||
|
||||
@router.get("/ha/states/{entity_id}")
|
||||
async def get_ha_state(entity_id: str) -> Response:
|
||||
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
|
||||
|
||||
@router.post("/ha/services/{domain}/{service}")
|
||||
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
|
||||
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
|
||||
|
||||
return router
|
||||
142
python-backend/musicmouse/services/web/hub.py
Normal file
142
python-backend/musicmouse/services/web/hub.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Getting state out to every open browser tab.
|
||||
|
||||
State events only fire on *change*, so a tab that connects halfway through a track
|
||||
would otherwise sit there knowing nothing. The fix is the one
|
||||
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
|
||||
broker: send a full snapshot on connect, then deltas.
|
||||
|
||||
Position is the exception to "everything goes on the bus". A progress bar wants it
|
||||
twice a second; an event at that rate would flood the queue, the MQTT service and the
|
||||
log to serve one front-end. So the hub reads it straight off the player on its own
|
||||
timer, and only while something is playing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
ClockStateChanged,
|
||||
ConnectionChanged,
|
||||
Event,
|
||||
FigureSelectionChanged,
|
||||
LedEffectChanged,
|
||||
PlaybackChanged,
|
||||
StateEvent,
|
||||
ToggleClockRequested,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.services.web.state import snapshot
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["StateHub"]
|
||||
|
||||
#: Twice a second: smooth enough once the client interpolates between frames, cheap
|
||||
#: enough to leave running.
|
||||
POSITION_INTERVAL = 0.5
|
||||
|
||||
#: The state events that change something in :func:`~musicmouse.services.web.state.snapshot`
|
||||
#: - each one costs every open tab a full snapshot. LED writes are the reason this is a
|
||||
#: list rather than "every StateEvent": a figure placed and removed is 9 LED writes,
|
||||
#: and the snapshot has no LED state in it.
|
||||
SNAPSHOT_EVENTS: tuple[type[StateEvent], ...] = (
|
||||
PlaybackChanged,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
ActiveFigureChanged,
|
||||
ConnectionChanged,
|
||||
ClockStateChanged,
|
||||
)
|
||||
|
||||
#: State events deliberately not pushed to the browser. Together with
|
||||
#: :data:`SNAPSHOT_EVENTS` this covers every :class:`StateEvent`; a test holds that, so
|
||||
#: a new state event has to be sorted into one or the other.
|
||||
#: The ring's album picker is shown on the LED ring, not in the browser.
|
||||
IGNORED_EVENTS: tuple[type[StateEvent], ...] = (LedEffectChanged, FigureSelectionChanged)
|
||||
|
||||
|
||||
class StateHub:
|
||||
def __init__(self, app: App) -> None:
|
||||
self.app = app
|
||||
self._clients: set[WebSocket] = set()
|
||||
self._unsubscribe: list[Any] = []
|
||||
|
||||
# ------------------------------------------------------------------- lifecycle
|
||||
|
||||
def start(self) -> None:
|
||||
self._unsubscribe = [
|
||||
*(self.app.bus.subscribe(kind, self._on_state) for kind in SNAPSHOT_EVENTS),
|
||||
# The clock overlay is the kiosk's own; the remote's key only asks for it.
|
||||
self.app.bus.subscribe(ToggleClockRequested, self._on_clock_toggle),
|
||||
]
|
||||
|
||||
def stop(self) -> None:
|
||||
for unsubscribe in self._unsubscribe:
|
||||
unsubscribe()
|
||||
self._unsubscribe = []
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Push the playback position while anything is playing."""
|
||||
while True:
|
||||
await asyncio.sleep(POSITION_INTERVAL)
|
||||
if not self._clients or not self.app.player.is_playing:
|
||||
continue
|
||||
await self.broadcast(
|
||||
{
|
||||
"type": "position",
|
||||
"position": self.app.player.position,
|
||||
"duration": self.app.player.duration,
|
||||
}
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- clients
|
||||
|
||||
async def connect(self, socket: WebSocket) -> None:
|
||||
await socket.accept()
|
||||
self._clients.add(socket)
|
||||
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
|
||||
|
||||
def disconnect(self, socket: WebSocket) -> None:
|
||||
self._clients.discard(socket)
|
||||
|
||||
# ------------------------------------------------------------------ publishing
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
if not self._clients:
|
||||
return
|
||||
payload = json.dumps(message)
|
||||
for socket in list(self._clients):
|
||||
try:
|
||||
await socket.send_text(payload)
|
||||
except Exception:
|
||||
# A tab that closed mid-send is normal, not an error worth logging loudly.
|
||||
_log.debug("Dropping a websocket client that went away")
|
||||
self._clients.discard(socket)
|
||||
|
||||
async def broadcast_state(self) -> None:
|
||||
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
|
||||
|
||||
async def broadcast_library(self) -> None:
|
||||
await self.broadcast({"type": "library"})
|
||||
|
||||
# ------------------------------------------------------------------ internals
|
||||
|
||||
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.send_text(json.dumps(message))
|
||||
|
||||
async def _on_state(self, _event: Event) -> None:
|
||||
await self.broadcast_state()
|
||||
|
||||
async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None:
|
||||
await self.broadcast({"type": "clock_toggle"})
|
||||
52
python-backend/musicmouse/services/web/remote_settings.py
Normal file
52
python-backend/musicmouse/services/web/remote_settings.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Reading and writing the IR remote's number-key mapping.
|
||||
|
||||
Same job as :mod:`musicmouse.services.web.settings`, for a dict-shaped config section
|
||||
rather than flat scalars: the ``remote:`` top-level key, not something under
|
||||
``general``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.config import Digit, RemoteSlotConfig
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.services.web.schemas import RemoteMappingOut, RemoteSlotOut
|
||||
from musicmouse.services.web.settings import atomic_write, load_document
|
||||
|
||||
__all__ = ["read_mapping", "write_mapping"]
|
||||
|
||||
|
||||
def _resolve(slot: RemoteSlotConfig, library: MusicLibrary) -> str | None:
|
||||
if slot.target_kind == "album":
|
||||
album = library.get(slot.target)
|
||||
else:
|
||||
album = library.latest_episode(slot.target)
|
||||
return album.id if album else None
|
||||
|
||||
|
||||
def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -> RemoteMappingOut:
|
||||
slots = [
|
||||
RemoteSlotOut(
|
||||
digit=digit,
|
||||
target_kind=slot.target_kind,
|
||||
target=slot.target,
|
||||
resolved_album_id=_resolve(slot, library),
|
||||
track_index=slot.track_index,
|
||||
)
|
||||
for digit, slot in sorted(remote.items())
|
||||
]
|
||||
return RemoteMappingOut(slots=slots)
|
||||
|
||||
|
||||
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
||||
document = load_document(path)
|
||||
remote: dict[str, dict[str, str | int]] = {}
|
||||
for digit, slot in mapping.items():
|
||||
entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target}
|
||||
# Only written when it says something, so a whole-album slot reads as before.
|
||||
if slot.track_index:
|
||||
entry["track_index"] = slot.track_index
|
||||
remote[digit] = entry
|
||||
document["remote"] = remote
|
||||
atomic_write(path, document)
|
||||
482
python-backend/musicmouse/services/web/schemas.py
Normal file
482
python-backend/musicmouse/services/web/schemas.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""What the browser sees.
|
||||
|
||||
Two rules shape these. The whole library ships in one response, because the design's
|
||||
incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB -
|
||||
so no track carries anything it does not need. And volume is a *percentage* here: the
|
||||
configured ceiling is a parent's business, not a child's, so it never crosses this line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from musicmouse.config import Digit
|
||||
from musicmouse.library import Album
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
from musicmouse.library.models import LibraryTrack
|
||||
from musicmouse.tippen.progress import AnimalId
|
||||
from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
|
||||
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
"BusOut",
|
||||
"ClockStateOut",
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
"RemoteMappingIn",
|
||||
"RemoteMappingOut",
|
||||
"RemoteSlotIn",
|
||||
"RemoteSlotOut",
|
||||
"SeekIn",
|
||||
"SettingsIn",
|
||||
"SettingsOut",
|
||||
"TimerIn",
|
||||
"TimerOut",
|
||||
"TippenCurriculumOut",
|
||||
"TippenGhostStrokeOut",
|
||||
"TippenKeyStatOut",
|
||||
"TippenLessonOut",
|
||||
"TippenLessonProgressOut",
|
||||
"TippenProgressOut",
|
||||
"TippenRewardOut",
|
||||
"TippenRunIn",
|
||||
"TippenRunOut",
|
||||
"TippenSettingsIn",
|
||||
"TippenSettingsOut",
|
||||
"TippenStreakOut",
|
||||
"TippenStrokeIn",
|
||||
"TippenUnlockedRewardOut",
|
||||
"TippenWorldOut",
|
||||
"TrackCurvesOut",
|
||||
"TrackDetailOut",
|
||||
"TrackOut",
|
||||
"UnlockHintOut",
|
||||
"VolumeIn",
|
||||
]
|
||||
|
||||
|
||||
class AnalysisOut(BaseModel):
|
||||
tempo: float | None = None
|
||||
energy: float | None = None
|
||||
valence: float | None = None
|
||||
brightness: float | None = None
|
||||
#: 0..1 confidence that `tempo` is an audible, steady beat - see `TrackAnalysis`.
|
||||
pulse: float | None = None
|
||||
beats: bool = False
|
||||
|
||||
@classmethod
|
||||
def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None:
|
||||
if analysis is None or not analysis.is_analyzed:
|
||||
return None
|
||||
return cls(
|
||||
tempo=analysis.tempo,
|
||||
energy=analysis.energy,
|
||||
valence=analysis.valence,
|
||||
brightness=analysis.brightness,
|
||||
pulse=analysis.pulse,
|
||||
beats=analysis.beats,
|
||||
)
|
||||
|
||||
|
||||
class UnlockHintOut(BaseModel):
|
||||
"""Which typing lesson unlocks a still-locked track, for "mention when this
|
||||
unlocks" in the browse view."""
|
||||
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
world_number: int
|
||||
world_title: str
|
||||
|
||||
@classmethod
|
||||
def of(cls, hint: UnlockHint) -> UnlockHintOut:
|
||||
return cls(
|
||||
lesson_id=hint.lesson_id,
|
||||
lesson_title=hint.lesson_title,
|
||||
world_number=hint.world_number,
|
||||
world_title=hint.world_title,
|
||||
)
|
||||
|
||||
|
||||
class TrackOut(BaseModel):
|
||||
title: str
|
||||
duration: float
|
||||
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
|
||||
analysis: AnalysisOut | None = None
|
||||
#: A reward-gated track not yet earned - the browse view shows a placeholder
|
||||
#: instead of the real title.
|
||||
locked: bool = False
|
||||
unlock_hint: UnlockHintOut | None = None
|
||||
|
||||
@classmethod
|
||||
def of(cls, track: LibraryTrack, lock: TrackLock | None = None) -> TrackOut:
|
||||
return cls(
|
||||
title=track.title,
|
||||
duration=track.duration,
|
||||
analysis=AnalysisOut.of(track.analysis),
|
||||
locked=lock.locked if lock is not None else False,
|
||||
unlock_hint=UnlockHintOut.of(lock.hint) if lock is not None and lock.hint else None,
|
||||
)
|
||||
|
||||
|
||||
def _cover_version(album: Album) -> int:
|
||||
if album.cover is None:
|
||||
return 0
|
||||
try:
|
||||
return album.cover.stat().st_mtime_ns // 1_000_000
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
class AlbumOut(BaseModel):
|
||||
id: str
|
||||
section: str
|
||||
kind: str
|
||||
title: str
|
||||
artist: str
|
||||
series: str | None
|
||||
figure: str | None
|
||||
category: str
|
||||
colors: list[str]
|
||||
has_cover: bool
|
||||
#: Cache-busting version of the cover (its mtime in ms; 0 with no cover). Goes in the
|
||||
#: cover URL's `v` so the browser may cache the image forever.
|
||||
cover_v: int = 0
|
||||
duration: float
|
||||
tracks: list[TrackOut]
|
||||
#: Every track is still locked - the browse view shows a question mark instead of
|
||||
#: cover art. `False` for an album no typing reward ever targets.
|
||||
locked: bool = False
|
||||
|
||||
@classmethod
|
||||
def of(cls, album: Album, lock: AlbumLock | None = None) -> AlbumOut:
|
||||
track_locks = lock.tracks if lock is not None else ()
|
||||
return cls(
|
||||
id=album.id,
|
||||
section=album.section,
|
||||
kind=album.kind,
|
||||
title=album.title,
|
||||
artist=album.artist,
|
||||
series=album.series,
|
||||
figure=album.figure,
|
||||
category=album.category,
|
||||
colors=list(album.colors),
|
||||
has_cover=album.cover is not None,
|
||||
cover_v=_cover_version(album),
|
||||
duration=album.duration,
|
||||
locked=lock.locked if lock is not None else False,
|
||||
tracks=[
|
||||
TrackOut.of(track, track_locks[i] if i < len(track_locks) else None)
|
||||
for i, track in enumerate(album.tracks)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class LibraryOut(BaseModel):
|
||||
albums: list[AlbumOut]
|
||||
|
||||
|
||||
class TrackCurvesOut(BaseModel):
|
||||
hop_seconds: float
|
||||
energy: list[float]
|
||||
valence: list[float]
|
||||
drive: list[float]
|
||||
|
||||
|
||||
class TrackDetailOut(BaseModel):
|
||||
"""Beats + mood/drive curves for the one track currently playing - fetched
|
||||
together since both are per-track detail the library payload never carries.
|
||||
``curve`` is ``None`` only when the whole track failed analysis; ``times``/
|
||||
``strengths`` are empty (not ``None``) for a track with no reliable beat, since a
|
||||
free-tempo or spoken-word track still has real energy/valence/drive curves."""
|
||||
|
||||
times: list[float]
|
||||
strengths: list[float]
|
||||
curve: TrackCurvesOut | None = None
|
||||
|
||||
|
||||
class ConnectionOut(BaseModel):
|
||||
firmware: bool
|
||||
mqtt: bool
|
||||
lirc: bool
|
||||
|
||||
|
||||
class BusOut(BaseModel):
|
||||
#: "HH:MM", when the bus leaves.
|
||||
time: str
|
||||
#: How long before ``time`` its countdown shows.
|
||||
lead_minutes: int
|
||||
|
||||
|
||||
class TimerOut(BaseModel):
|
||||
#: Wall-clock Unix seconds.
|
||||
started_at: float
|
||||
ends_at: float
|
||||
ringing: bool
|
||||
|
||||
|
||||
class ClockStateOut(BaseModel):
|
||||
"""What the clock overlay shows besides the time."""
|
||||
|
||||
#: ``None`` when the holiday calendar could not be fetched; weekends are known
|
||||
#: regardless.
|
||||
school_day: bool | None
|
||||
#: Why today is no school day: "Wochenende", "Herbstferien", ...
|
||||
school_day_reason: str | None
|
||||
#: Today's bus, or ``None`` when there is no school today.
|
||||
bus: BusOut | None
|
||||
parent_timer: TimerOut | None
|
||||
|
||||
|
||||
class PlayerStateOut(BaseModel):
|
||||
playing: bool
|
||||
album_id: str | None
|
||||
album_title: str | None
|
||||
artist: str | None
|
||||
kind: str | None
|
||||
track_index: int
|
||||
track_title: str | None
|
||||
track_count: int
|
||||
position: float
|
||||
duration: float
|
||||
#: 0..100. The device's own range is deliberately not exposed.
|
||||
volume: int
|
||||
active_figure: str | None
|
||||
connected: ConnectionOut
|
||||
clock: ClockStateOut
|
||||
|
||||
|
||||
class PlayIn(BaseModel):
|
||||
album_id: str
|
||||
track_index: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class SeekIn(BaseModel):
|
||||
position: float = Field(ge=0)
|
||||
|
||||
|
||||
class VolumeIn(BaseModel):
|
||||
"""An absolute percentage or a number of steps; exactly one of the two.
|
||||
|
||||
``steps`` moves by ``volume_increment`` device units per step - the same step the
|
||||
IR remote's volume keys and the rotary encoder take - rather than by a percentage.
|
||||
"""
|
||||
|
||||
percent: int | None = Field(default=None, ge=0, le=100)
|
||||
steps: int | None = Field(default=None, ge=-10, le=10)
|
||||
|
||||
|
||||
class TimerIn(BaseModel):
|
||||
"""Minutes from now, or a time of day ("HH:MM", tomorrow if already past); exactly
|
||||
one of the two."""
|
||||
|
||||
minutes: int | None = Field(default=None, ge=1, le=12 * 60)
|
||||
until: str | None = Field(default=None, pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _exactly_one(self) -> Self:
|
||||
if (self.minutes is None) == (self.until is None):
|
||||
raise ValueError("give exactly one of minutes and until")
|
||||
return self
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
min_volume: int
|
||||
max_volume: int
|
||||
initial_volume: int
|
||||
volume_increment: int
|
||||
button_leds_brightness: float
|
||||
effects_volume: int
|
||||
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
|
||||
#: switch off (no ``screen`` section, or one without an ``off_command``).
|
||||
screen_auto_off_minutes: int | None
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
min_volume: int = Field(ge=0, le=200)
|
||||
max_volume: int = Field(ge=0, le=200)
|
||||
initial_volume: int = Field(ge=0, le=200)
|
||||
volume_increment: int = Field(ge=1, le=100)
|
||||
button_leds_brightness: float = Field(ge=0, le=1)
|
||||
#: UI sounds relative to the music, in percent.
|
||||
effects_volume: int = Field(ge=0, le=100)
|
||||
#: Ignored without a screen section. ``None`` leaves it as it is.
|
||||
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
|
||||
|
||||
|
||||
class HaDeviceOut(BaseModel):
|
||||
entity_id: str
|
||||
name: str | None
|
||||
|
||||
|
||||
class HaConfigOut(BaseModel):
|
||||
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
|
||||
proxies to Home Assistant with the token attached server-side. See
|
||||
``services/web/api.py``'s room-control section."""
|
||||
|
||||
devices: list[HaDeviceOut]
|
||||
scenes: list[HaDeviceOut]
|
||||
|
||||
|
||||
class RemoteSlotOut(BaseModel):
|
||||
digit: str
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
#: The album this slot resolves to *right now* - the fixed album for "album" slots,
|
||||
#: or today's newest episode for "series" slots. `None` when the target no longer
|
||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||
#: broken-assignment state instead of silently dropping it.
|
||||
resolved_album_id: str | None
|
||||
#: "album" slots only: the song the key starts at; 0 for the whole album.
|
||||
track_index: int = 0
|
||||
|
||||
|
||||
class RemoteMappingOut(BaseModel):
|
||||
slots: list[RemoteSlotOut]
|
||||
|
||||
|
||||
class RemoteSlotIn(BaseModel):
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
track_index: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class RemoteMappingIn(BaseModel):
|
||||
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
|
||||
|
||||
slots: dict[Digit, RemoteSlotIn]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- tippen
|
||||
|
||||
|
||||
class TippenRewardOut(BaseModel):
|
||||
"""What a lesson's ``unlocks:`` resolves to right now - `resolved` is `False` when
|
||||
the configured path matches nothing in the current library (a moved or mistyped
|
||||
path), so the frontend can show a broken-reward state instead of silently
|
||||
dropping it."""
|
||||
|
||||
resolved: bool
|
||||
album_id: str | None = None
|
||||
has_cover: bool = False
|
||||
kind: Literal["tracks", "episode"] | None = None
|
||||
|
||||
|
||||
class TippenLessonOut(BaseModel):
|
||||
id: str
|
||||
world: int
|
||||
number: int
|
||||
title: str
|
||||
subtitle: str
|
||||
kind: str
|
||||
new_keys: list[str]
|
||||
spotlight_keys: list[str]
|
||||
emphasis: Literal["isolated", "mixed"] | None
|
||||
active_keys: list[str]
|
||||
primary_mode: str
|
||||
bonus_modes: list[str]
|
||||
words: list[str]
|
||||
is_drill: bool
|
||||
chunks: int
|
||||
chunk_size: int
|
||||
reward: TippenRewardOut
|
||||
|
||||
|
||||
class TippenWorldOut(BaseModel):
|
||||
number: int
|
||||
title: str
|
||||
emoji: str
|
||||
reward: str
|
||||
|
||||
|
||||
class TippenCurriculumOut(BaseModel):
|
||||
worlds: list[TippenWorldOut]
|
||||
lessons: list[TippenLessonOut]
|
||||
|
||||
|
||||
class TippenGhostStrokeOut(BaseModel):
|
||||
key: str
|
||||
at: float
|
||||
|
||||
|
||||
class TippenLessonProgressOut(BaseModel):
|
||||
unlocked: bool
|
||||
runs: int
|
||||
best_stars: int
|
||||
best_animal: AnimalId | None
|
||||
best_points: float
|
||||
#: Derived, not stored - see ``LessonProgress.earned``.
|
||||
earned: bool
|
||||
ghost: list[TippenGhostStrokeOut] | None
|
||||
|
||||
|
||||
class TippenKeyStatOut(BaseModel):
|
||||
ema: float
|
||||
attempts: int
|
||||
errors: int
|
||||
|
||||
|
||||
class TippenStreakOut(BaseModel):
|
||||
days: int
|
||||
last_played: str | None
|
||||
|
||||
|
||||
class TippenSettingsOut(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenSettingsIn(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenProgressOut(BaseModel):
|
||||
lessons: dict[str, TippenLessonProgressOut]
|
||||
key_stats: dict[str, TippenKeyStatOut]
|
||||
aquarium: list[str]
|
||||
streak: TippenStreakOut
|
||||
settings: TippenSettingsOut
|
||||
|
||||
|
||||
class TippenStrokeIn(BaseModel):
|
||||
key: str
|
||||
expected: str
|
||||
correct: bool
|
||||
#: ms timestamp, from the run's own clock.
|
||||
at: float
|
||||
|
||||
|
||||
class TippenRunIn(BaseModel):
|
||||
"""A run the client already graded - see ``grading.ts``. Grading itself stays
|
||||
client-side; this only tells the backend what to do with progress."""
|
||||
|
||||
lesson_id: str
|
||||
stars: Literal[0, 1, 2, 3]
|
||||
animal: AnimalId
|
||||
points: float
|
||||
passed: bool
|
||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TippenUnlockedRewardOut(BaseModel):
|
||||
"""The literal track/episode this run's lesson names in its own ``unlocks:`` -
|
||||
what the unlock animation shows, via the existing ``/api/albums/{id}/cover``."""
|
||||
|
||||
album_id: str
|
||||
title: str
|
||||
has_cover: bool
|
||||
kind: Literal["album", "book", "podcast_episode"]
|
||||
|
||||
|
||||
class TippenRunOut(BaseModel):
|
||||
progress: TippenProgressOut
|
||||
unlocked_lesson_id: str | None
|
||||
unlocked_lesson_title: str | None
|
||||
new_creature: str | None
|
||||
is_new_best: bool
|
||||
unlocked_reward: TippenUnlockedRewardOut | None
|
||||
115
python-backend/musicmouse/services/web/service.py
Normal file
115
python-backend/musicmouse/services/web/service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
|
||||
|
||||
Everything runs on the one event loop the rest of the app already has: uvicorn's
|
||||
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
|
||||
service owns no state of its own - it turns HTTP into intents on the bus, and bus
|
||||
events into websocket frames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.responses import Response
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import WebConfig
|
||||
from musicmouse.services.web.api import build_router
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["WebService", "build_app"]
|
||||
|
||||
|
||||
class _WebFiles(StaticFiles):
|
||||
"""Vite writes ``assets/<name>-<content hash>.<ext>``: a URL there names one exact
|
||||
set of bytes, so the browser may keep it forever and never revalidate. Everything
|
||||
else (``index.html``, ``sw.js``) has a stable name and keeps the default behaviour."""
|
||||
|
||||
def file_response(self, full_path, stat_result, scope, status_code=200) -> Response: # type: ignore[no-untyped-def]
|
||||
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||
if scope.get("path", "").startswith("/assets/"):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
def build_app(
|
||||
app: App,
|
||||
config: WebConfig,
|
||||
config_path: Path,
|
||||
ha_client: httpx2.AsyncClient | None = None,
|
||||
) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
||||
|
||||
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
||||
calls through; tests inject one on a mock transport, production gets a real one
|
||||
that :class:`WebService` closes on shutdown.
|
||||
"""
|
||||
hub = StateHub(app)
|
||||
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
||||
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
api.state.ha_client = ha_client
|
||||
api.include_router(build_router(app, hub, config_path, ha_client))
|
||||
|
||||
if config.static_dir is not None:
|
||||
if config.static_dir.is_dir():
|
||||
# Mounted last and at the root so every /api route still wins; html=True
|
||||
# falls back to index.html, which is what a client-side router needs.
|
||||
api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web")
|
||||
else:
|
||||
_log.warning(
|
||||
"web.static_dir %s does not exist; serving the API only "
|
||||
"(run `npm run build` in web/, or drop the setting)",
|
||||
config.static_dir,
|
||||
)
|
||||
return api, hub
|
||||
|
||||
|
||||
class WebService:
|
||||
name = "web"
|
||||
|
||||
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
|
||||
self.config = config
|
||||
self.api, self.hub = build_app(app, config, config_path)
|
||||
|
||||
async def run(self) -> None:
|
||||
self.hub.start()
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
self.api,
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
# Access logs for a progress-bar poll every 500ms are noise.
|
||||
access_log=False,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
|
||||
|
||||
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
|
||||
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
|
||||
position = asyncio.create_task(self.hub.run(), name="web-position")
|
||||
serving = asyncio.create_task(server.serve(), name="web-serve")
|
||||
try:
|
||||
await asyncio.shield(serving)
|
||||
except asyncio.CancelledError:
|
||||
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
|
||||
# would leave its listening socket to the garbage collector, so ask it to
|
||||
# wind down and wait for it to let the port go.
|
||||
server.should_exit = True
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await asyncio.wait_for(serving, timeout=5.0)
|
||||
raise
|
||||
finally:
|
||||
position.cancel()
|
||||
serving.cancel()
|
||||
self.hub.stop()
|
||||
await self.api.state.ha_client.aclose()
|
||||
106
python-backend/musicmouse/services/web/settings.py
Normal file
106
python-backend/musicmouse/services/web/settings.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Volume as a percentage, and the handful of settings parent mode may change.
|
||||
|
||||
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
|
||||
|
||||
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
|
||||
encoder and the firmware carry on speaking device units, unaware that a browser is
|
||||
using a different scale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.config import GeneralConfig
|
||||
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"atomic_write",
|
||||
"load_document",
|
||||
"read_settings",
|
||||
"to_device_volume",
|
||||
"to_percent",
|
||||
"write_settings",
|
||||
]
|
||||
|
||||
|
||||
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return general.min_volume
|
||||
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
|
||||
|
||||
|
||||
def to_percent(volume: int, general: GeneralConfig) -> int:
|
||||
"""The inverse, for the state snapshot."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return 100
|
||||
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
|
||||
|
||||
|
||||
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||
screen = general.screen
|
||||
return SettingsOut(
|
||||
min_volume=general.min_volume,
|
||||
max_volume=general.max_volume,
|
||||
initial_volume=general.initial_volume,
|
||||
volume_increment=general.volume_increment,
|
||||
button_leds_brightness=general.button_leds_brightness,
|
||||
effects_volume=general.effects_volume,
|
||||
screen_auto_off_minutes=(
|
||||
screen.auto_off_minutes if screen is not None and screen.off_command else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _yaml() -> YAML:
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
return yaml
|
||||
|
||||
|
||||
def load_document(path: Path) -> Any:
|
||||
"""The config file, round-trip parsed so it keeps its comments and formatting.
|
||||
|
||||
Shared by every writer that patches ``config.yml`` in place - a config that
|
||||
explains itself is worth more than one a save could rewrite from scratch.
|
||||
"""
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return _yaml().load(handle)
|
||||
|
||||
|
||||
def atomic_write(path: Path, document: Any) -> None:
|
||||
"""Write a round-trip-loaded document back, through a sibling temp file so an
|
||||
interrupted save cannot truncate the real one."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
try:
|
||||
with temp.open("w", encoding="utf-8") as handle:
|
||||
_yaml().dump(document, handle)
|
||||
temp.replace(path)
|
||||
except BaseException:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise
|
||||
_log.info("Wrote settings to %s", path)
|
||||
|
||||
|
||||
def write_settings(path: Path, settings: SettingsIn) -> None:
|
||||
"""Patch the settings into ``config.yml`` in place."""
|
||||
document = load_document(path)
|
||||
general = document["general"]
|
||||
values = settings.model_dump()
|
||||
# The one setting that lives in a subsection rather than flat under `general`.
|
||||
auto_off = values.pop("screen_auto_off_minutes")
|
||||
for key, value in values.items():
|
||||
general[key] = value
|
||||
if auto_off is not None and general.get("screen") is not None:
|
||||
general["screen"]["auto_off_minutes"] = auto_off
|
||||
atomic_write(path, document)
|
||||
79
python-backend/musicmouse/services/web/state.py
Normal file
79
python-backend/musicmouse/services/web/state.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""One place that answers "what is the mouse doing right now?".
|
||||
|
||||
Assembled on demand rather than accumulated from events. The join between what is
|
||||
playing and which figure is on the reader is the same one the MQTT player entity makes:
|
||||
read ``mouse.active_figure``, do not keep a second copy of it that can drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.services.web.schemas import (
|
||||
BusOut,
|
||||
ClockStateOut,
|
||||
ConnectionOut,
|
||||
PlayerStateOut,
|
||||
TimerOut,
|
||||
)
|
||||
from musicmouse.services.web.settings import to_percent
|
||||
|
||||
__all__ = ["snapshot"]
|
||||
|
||||
|
||||
def snapshot(app: App) -> PlayerStateOut:
|
||||
player = app.player
|
||||
playlist = player.playlist
|
||||
album = app.album_for(playlist)
|
||||
index = player.track_index
|
||||
# The player only knows a file path, and a tagged file's name is rarely its title
|
||||
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
|
||||
indexed = (
|
||||
album.tracks[index] if album and 0 <= index < len(album.tracks) else None
|
||||
)
|
||||
track = player.current_track
|
||||
|
||||
return PlayerStateOut(
|
||||
playing=player.is_playing,
|
||||
album_id=album.id if album else None,
|
||||
album_title=album.title if album else (playlist.name if playlist else None),
|
||||
artist=album.artist if album else None,
|
||||
kind=album.kind if album else None,
|
||||
track_index=index,
|
||||
track_title=indexed.title if indexed else (track.title if track else None),
|
||||
track_count=len(playlist) if playlist else 0,
|
||||
position=player.position,
|
||||
# Likewise the duration: the player only has one once libVLC has opened the file.
|
||||
duration=(indexed.duration if indexed else 0.0) or player.duration,
|
||||
volume=to_percent(player.volume, app.config.general),
|
||||
active_figure=app.mouse.active_figure,
|
||||
connected=ConnectionOut(
|
||||
firmware=app.mouse.connected,
|
||||
mqtt=app.state.mqtt_connected,
|
||||
lirc=app.state.lirc_connected,
|
||||
),
|
||||
clock=clock_state(app),
|
||||
)
|
||||
|
||||
|
||||
def clock_state(app: App, today: date | None = None) -> ClockStateOut:
|
||||
state = app.state
|
||||
config = app.config.general.clock
|
||||
# Without a holiday calendar a weekday counts as a school day: a bus countdown on
|
||||
# a holiday is a smaller surprise than none on the first day back.
|
||||
today = today or date.today()
|
||||
school = state.school_day if state.school_day is not None else today.weekday() < 5
|
||||
timer = state.parent_timer
|
||||
return ClockStateOut(
|
||||
school_day=state.school_day,
|
||||
school_day_reason=state.school_day_reason,
|
||||
bus=BusOut(time=config.bus_time, lead_minutes=config.bus_lead_minutes)
|
||||
if school
|
||||
else None,
|
||||
parent_timer=TimerOut(
|
||||
started_at=timer.started_at, ends_at=timer.ends_at, ringing=timer.ringing
|
||||
)
|
||||
if timer
|
||||
else None,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user