studio-engine 0.120.0 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/README.md +2 -0
- data/app/controllers/studio/voice_takes_controller.rb +146 -0
- data/app/javascript/studio/controllers/microphone_chip_controller.js +21 -0
- data/app/javascript/studio/controllers/voice_recorder_controller.js +29 -0
- data/app/javascript/studio/level_meter.js +167 -0
- data/app/javascript/studio/microphone.js +250 -0
- data/app/javascript/studio/microphone_chip.js +374 -0
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/javascript/studio/voice_profile.js +38 -113
- data/app/javascript/studio/voice_recorder.js +363 -0
- data/app/views/studio/voice/_microphone_chip.html.erb +45 -0
- data/app/views/studio/voice/_microphone_help.html.erb +65 -0
- data/app/views/studio/voice/_recorder.html.erb +34 -0
- data/app/views/studio/voice/_styles.html.erb +94 -0
- data/lib/studio/engine.rb +3 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_transcription.rb +470 -0
- data/lib/studio.rb +45 -0
- metadata +15 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%# "How to change your microphone in Chrome": a native modal <dialog> inside
|
|
2
|
+
the microphone chip (studio/voice/_microphone_chip). showModal() makes the
|
|
3
|
+
page behind it inert, so focus stays inside; Esc and Close shut it, and
|
|
4
|
+
focus goes back where it was (studio/microphone_chip). It opens after a
|
|
5
|
+
switch, on a refused microphone, on the second silent take in a row on one
|
|
6
|
+
microphone, and from the picker's help link. The lead line on top says
|
|
7
|
+
which; the browser decides between Chrome's steps and one generic line
|
|
8
|
+
(browserFamily, studio/microphone). The picture is a drawn, simplified
|
|
9
|
+
address bar, never a screenshot.
|
|
10
|
+
|
|
11
|
+
Locals: copy (the chip's merged copy), title_id. %>
|
|
12
|
+
<% help = copy.fetch("help") %>
|
|
13
|
+
<dialog class="studio-mic-help" data-microphone-dialog aria-labelledby="<%= title_id %>">
|
|
14
|
+
<div class="studio-mic-help-body">
|
|
15
|
+
<div class="studio-mic-help-lead" data-microphone-help-lead hidden>
|
|
16
|
+
<p class="studio-mic-help-headline" data-microphone-help-headline></p>
|
|
17
|
+
<p class="studio-mic-help-tip" data-microphone-help-tip></p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<h2 id="<%= title_id %>" class="studio-mic-help-title" data-microphone-help-title><%= help.fetch("title_chrome") %></h2>
|
|
21
|
+
|
|
22
|
+
<div class="studio-mic-help-chrome" data-microphone-help-chrome>
|
|
23
|
+
<svg class="studio-mic-mock" viewBox="0 0 320 168" role="img" aria-label="A drawing of Chrome's address bar: the site settings icon left of the address is circled, and below it the Microphone setting is on with its list of microphones.">
|
|
24
|
+
<rect x="4" y="6" width="312" height="36" rx="18" class="mock-bar"/>
|
|
25
|
+
<circle cx="26" cy="24" r="14" class="mock-ring"/>
|
|
26
|
+
<g class="mock-ink" stroke-width="2" stroke-linecap="round">
|
|
27
|
+
<line x1="19" y1="20" x2="33" y2="20"/><circle cx="23" cy="20" r="2.5" class="mock-knob"/>
|
|
28
|
+
<line x1="19" y1="28" x2="33" y2="28"/><circle cx="29" cy="28" r="2.5" class="mock-knob"/>
|
|
29
|
+
</g>
|
|
30
|
+
<%# The address is a drawn bar, not this app's host: the drawing is the
|
|
31
|
+
same on every app, and renders without a request. %>
|
|
32
|
+
<rect x="48" y="19" width="132" height="10" rx="5" class="mock-address"/>
|
|
33
|
+
<rect x="12" y="54" width="232" height="106" rx="10" class="mock-panel"/>
|
|
34
|
+
<g class="mock-ink" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
35
|
+
<rect x="26" y="68" width="8" height="13" rx="4"/><path d="M23 77a7 7 0 0 0 14 0M30 84v4"/>
|
|
36
|
+
</g>
|
|
37
|
+
<text x="46" y="82" class="mock-text" font-size="13" font-weight="600">Microphone</text>
|
|
38
|
+
<rect x="196" y="70" width="34" height="18" rx="9" class="mock-on"/>
|
|
39
|
+
<circle cx="221" cy="79" r="6" class="mock-knob-on"/>
|
|
40
|
+
<rect x="26" y="102" width="204" height="44" rx="8" class="mock-ring"/>
|
|
41
|
+
<text x="38" y="129" class="mock-text" font-size="12">MacBook Pro Microphone</text>
|
|
42
|
+
<path d="M212 120l5 5 5-5" class="mock-ink" stroke-width="2" fill="none" stroke-linecap="round"/>
|
|
43
|
+
<g font-size="11" font-weight="700" text-anchor="middle">
|
|
44
|
+
<circle cx="50" cy="8" r="8" class="mock-badge"/><text x="50" y="12" class="mock-badge-text">1</text>
|
|
45
|
+
<circle cx="252" cy="70" r="8" class="mock-badge"/><text x="252" y="74" class="mock-badge-text">2</text>
|
|
46
|
+
<circle cx="252" cy="118" r="8" class="mock-badge"/><text x="252" y="122" class="mock-badge-text">3</text>
|
|
47
|
+
</g>
|
|
48
|
+
</svg>
|
|
49
|
+
<ol class="studio-mic-help-steps" data-microphone-help-steps>
|
|
50
|
+
<% help.fetch("steps_chrome").each_with_index do |step, i| %>
|
|
51
|
+
<li>
|
|
52
|
+
<span class="studio-mic-step" aria-hidden="true"><%= i + 1 %></span>
|
|
53
|
+
<span><%= step %></span>
|
|
54
|
+
</li>
|
|
55
|
+
<% end %>
|
|
56
|
+
</ol>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<p class="studio-mic-help-other" data-microphone-help-other hidden><%= help.fetch("other") %></p>
|
|
60
|
+
|
|
61
|
+
<form method="dialog" class="studio-mic-help-actions">
|
|
62
|
+
<button type="submit" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
|
|
63
|
+
</form>
|
|
64
|
+
</div>
|
|
65
|
+
</dialog>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# The record button (docs/VOICE_CAPTURE.md): studio/voice_recorder opens the
|
|
2
|
+
microphone through studio/microphone (so it records from what the chip
|
|
3
|
+
names), runs the engine's one level meter (studio/level_meter) as the ring
|
|
4
|
+
around the button, refuses a silent take, and hands a heard take to the page
|
|
5
|
+
as a studio:voice-recorder:take event with the blob. It uploads nothing:
|
|
6
|
+
the page that renders it decides where the audio goes.
|
|
7
|
+
|
|
8
|
+
Locals, all optional:
|
|
9
|
+
copy: per-state words merged over studio/voice_recorder's COPY
|
|
10
|
+
(e.g. { "idle" => { "button" => "Talk" } })
|
|
11
|
+
mode: "toggle" (tap to start, tap to stop; the default) or "hold"
|
|
12
|
+
(press and hold to talk)
|
|
13
|
+
max_ms: stop a take on its own after this long (default 60000)
|
|
14
|
+
id: an id for the root, for a page's own listener
|
|
15
|
+
|
|
16
|
+
The controller is lazy (studio/stimulus); if it fails to load, the root
|
|
17
|
+
carries data-studio-controller-failed="voice-recorder" and says so. %>
|
|
18
|
+
<% copy = Studio::VoiceCapture.recorder_copy(local_assigns[:copy]) %>
|
|
19
|
+
<% mode = local_assigns.fetch(:mode, "toggle").to_s == "hold" ? "hold" : "toggle" %>
|
|
20
|
+
<%= render "studio/voice/styles" %>
|
|
21
|
+
<div class="studio-recorder"
|
|
22
|
+
<%= tag.attributes(id: local_assigns[:id]) %>
|
|
23
|
+
data-studio-controller="voice-recorder"
|
|
24
|
+
data-voice-recorder-state="idle"
|
|
25
|
+
data-voice-recorder-mode="<%= mode %>"
|
|
26
|
+
data-voice-recorder-max-ms="<%= local_assigns.fetch(:max_ms, 60_000).to_i %>"
|
|
27
|
+
data-voice-recorder-copy="<%= copy.except("unavailable").to_json %>">
|
|
28
|
+
<button type="button" class="studio-recorder-button" data-voice-recorder-button aria-pressed="false">
|
|
29
|
+
<span data-voice-recorder-label><%= copy.dig("idle", "button") %></span>
|
|
30
|
+
</button>
|
|
31
|
+
<span class="studio-recorder-timer" data-voice-recorder-timer aria-hidden="true">0:00</span>
|
|
32
|
+
<p class="studio-recorder-status" data-voice-recorder-status role="status" aria-live="polite"></p>
|
|
33
|
+
<p class="studio-recorder-failed"><%= copy["unavailable"] %></p>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<%# The voice capture layer's scoped CSS (docs/VOICE_CAPTURE.md), rendered by
|
|
2
|
+
the chip and the recorder partials. Scoped classes rather than utilities, as
|
|
3
|
+
in studio/profiles/_voice_section: the chip, the dot, the ring and the help
|
|
4
|
+
drawing must render in an app whose Tailwind build never saw these files.
|
|
5
|
+
Every colour is a theme token (the primary through --color-primary-500-rgb
|
|
6
|
+
and the CTA fill under its label), so dark mode and each app's palette come
|
|
7
|
+
for free. Tap targets are 44px. A page rendering both partials carries this
|
|
8
|
+
block twice, which is harmless. %>
|
|
9
|
+
<style>
|
|
10
|
+
.studio-mic { display: flex; width: 100%; flex-direction: column; align-items: center; gap: .5rem; }
|
|
11
|
+
.studio-mic-chip { display: inline-flex; min-height: 44px; max-width: 100%; align-items: center; gap: .5rem;
|
|
12
|
+
border-radius: 1.5rem; border: 1px solid var(--color-border, rgb(127 127 127 / .25));
|
|
13
|
+
background: var(--color-inset, rgb(127 127 127 / .12)); padding: .5rem 1rem; font-size: .875rem; font-weight: 600;
|
|
14
|
+
color: var(--color-text, inherit); cursor: pointer; text-align: left; }
|
|
15
|
+
.studio-mic-chip:focus-visible { outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 2px; }
|
|
16
|
+
.studio-mic-icon { width: 1rem; height: 1rem; flex-shrink: 0; color: var(--color-text-secondary, currentColor); }
|
|
17
|
+
.studio-mic-name { min-width: 0; overflow-wrap: anywhere; }
|
|
18
|
+
.studio-mic-caret { width: .75rem; height: .75rem; flex-shrink: 0; color: var(--color-text-muted, currentColor); }
|
|
19
|
+
.studio-mic-chip[aria-expanded="true"] .studio-mic-caret { transform: rotate(180deg); }
|
|
20
|
+
[data-microphone-state="denied"] .studio-mic-chip { border-color: rgb(var(--color-primary-500-rgb, 99 102 241)); }
|
|
21
|
+
/* The live dot: hollow while nothing records; while a take records it fills
|
|
22
|
+
with the primary colour as loud as the voice (--studio-mic-level, 0..1). */
|
|
23
|
+
.studio-mic-dot { width: .625rem; height: .625rem; flex-shrink: 0; border-radius: 9999px; border: 2px solid currentColor; opacity: .35; }
|
|
24
|
+
[data-microphone-live="true"] .studio-mic-dot { opacity: 1; border-color: rgb(var(--color-primary-500-rgb, 99 102 241));
|
|
25
|
+
background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / calc(.15 + var(--studio-mic-level, 0) * .85));
|
|
26
|
+
transform: scale(calc(1 + var(--studio-mic-level, 0) * .5)); transition: transform 80ms linear, background-color 80ms linear; }
|
|
27
|
+
.studio-mic-panel { width: 100%; max-width: 24rem; padding: .75rem; text-align: left; display: grid; gap: .5rem; }
|
|
28
|
+
.studio-mic-panel[hidden] { display: none; }
|
|
29
|
+
.studio-mic-note { font-size: .875rem; color: var(--color-text-muted, inherit); }
|
|
30
|
+
.studio-mic-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .25rem; }
|
|
31
|
+
.studio-mic-option { display: flex; min-height: 44px; width: 100%; align-items: center; gap: .5rem; border-radius: .75rem;
|
|
32
|
+
border: 1px solid transparent; background: transparent; padding: .5rem .75rem; text-align: left; font-size: .875rem;
|
|
33
|
+
color: var(--color-text, inherit); cursor: pointer; }
|
|
34
|
+
.studio-mic-option:hover { background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .08); }
|
|
35
|
+
/* The same 3px ring as the chip on every control in the picker. */
|
|
36
|
+
.studio-mic-option:focus-visible, .studio-mic-help-link:focus-visible {
|
|
37
|
+
outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 2px; }
|
|
38
|
+
.studio-mic-option[aria-current="true"] { border-color: rgb(var(--color-primary-500-rgb, 99 102 241));
|
|
39
|
+
background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .12); font-weight: 600; }
|
|
40
|
+
.studio-mic-check { width: 1rem; flex-shrink: 0; font-weight: 700; color: rgb(var(--color-primary-500-rgb, 99 102 241)); }
|
|
41
|
+
.studio-mic-option-label { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
|
42
|
+
.studio-mic-in-use { flex-shrink: 0; border-radius: 9999px; padding: .125rem .5rem; font-size: .75rem; font-weight: 700;
|
|
43
|
+
background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241)))); color: var(--btn-primary-fg, #fff); }
|
|
44
|
+
.studio-mic-help-link { min-height: 44px; width: 100%; border-radius: .75rem; padding: 0 .75rem; text-align: left; font-size: .875rem;
|
|
45
|
+
font-weight: 600; color: var(--color-text-secondary, inherit); text-decoration: underline; background: transparent; border: 0; cursor: pointer; }
|
|
46
|
+
/* The help dialog: a native modal <dialog>, so the page behind it is inert. */
|
|
47
|
+
.studio-mic-help { border: 0; padding: 0; margin: auto; width: min(28rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem);
|
|
48
|
+
overflow-y: auto; text-align: left; color: var(--color-text-body, inherit); background: var(--color-surface, Canvas); border-radius: 1rem; }
|
|
49
|
+
.studio-mic-help::backdrop { background-color: rgb(0 0 0 / .55); }
|
|
50
|
+
.studio-mic-help-body { display: grid; gap: 1rem; padding: 1.25rem; }
|
|
51
|
+
.studio-mic-help-lead { display: grid; gap: .25rem; border-radius: .75rem; background: var(--color-inset, rgb(127 127 127 / .12)); padding: .75rem; }
|
|
52
|
+
.studio-mic-help-lead[hidden], .studio-mic-help-chrome[hidden], .studio-mic-help-other[hidden] { display: none; }
|
|
53
|
+
.studio-mic-help-headline { font-weight: 600; color: var(--color-text, inherit); margin: 0; }
|
|
54
|
+
.studio-mic-help-tip { font-size: .875rem; color: var(--color-text-secondary, inherit); margin: 0; }
|
|
55
|
+
.studio-mic-help-title { font-size: 1.25rem; font-weight: 700; color: var(--color-text, inherit); margin: 0; }
|
|
56
|
+
.studio-mic-help-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
|
|
57
|
+
.studio-mic-help-steps li { display: flex; align-items: flex-start; gap: .75rem; }
|
|
58
|
+
.studio-mic-step { display: flex; width: 1.5rem; height: 1.5rem; flex-shrink: 0; align-items: center; justify-content: center;
|
|
59
|
+
border-radius: 9999px; font-size: .75rem; font-weight: 700;
|
|
60
|
+
background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241)))); color: var(--btn-primary-fg, #fff); }
|
|
61
|
+
.studio-mic-help-actions { display: flex; justify-content: flex-end; margin: 0; }
|
|
62
|
+
.studio-mic-help-close { min-height: 44px; padding: 0 1.5rem; }
|
|
63
|
+
/* The drawn address bar: never a screenshot, every stroke a token. */
|
|
64
|
+
.studio-mic-mock { width: 100%; height: auto; color: var(--color-text, currentColor); }
|
|
65
|
+
.studio-mic-mock .mock-bar, .studio-mic-mock .mock-panel { fill: var(--color-inset, rgb(127 127 127 / .12)); stroke: currentColor; stroke-opacity: .25; }
|
|
66
|
+
.studio-mic-mock .mock-ring { fill: none; stroke: rgb(var(--color-primary-500-rgb, 99 102 241)); stroke-width: 3; }
|
|
67
|
+
.studio-mic-mock .mock-ink { stroke: currentColor; }
|
|
68
|
+
.studio-mic-mock .mock-knob { fill: currentColor; stroke: none; }
|
|
69
|
+
.studio-mic-mock .mock-text { fill: currentColor; font-family: inherit; }
|
|
70
|
+
.studio-mic-mock .mock-address { fill: currentColor; fill-opacity: .3; }
|
|
71
|
+
.studio-mic-mock .mock-on, .studio-mic-mock .mock-badge { fill: rgb(var(--color-primary-500-rgb, 99 102 241)); }
|
|
72
|
+
.studio-mic-mock .mock-knob-on, .studio-mic-mock .mock-badge-text { fill: var(--color-surface, Canvas); }
|
|
73
|
+
|
|
74
|
+
/* The record button: a ring as loud as the voice (--studio-recorder-level). */
|
|
75
|
+
.studio-recorder { display: flex; flex-direction: column; align-items: center; gap: .75rem; text-align: center; }
|
|
76
|
+
.studio-recorder-button { min-height: 44px; min-width: 12rem; max-width: 100%; border: 0; border-radius: 9999px; padding: .875rem 1.75rem;
|
|
77
|
+
font-weight: 700; cursor: pointer; touch-action: none; -webkit-user-select: none; user-select: none;
|
|
78
|
+
color: var(--btn-primary-fg, #fff); background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241))));
|
|
79
|
+
box-shadow: 0 0 0 calc(var(--studio-recorder-level, 0) * 14px) rgb(var(--color-primary-500-rgb, 99 102 241) / .3);
|
|
80
|
+
transition: box-shadow 80ms linear; }
|
|
81
|
+
.studio-recorder-button:focus-visible { outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 3px; }
|
|
82
|
+
.studio-recorder-button:disabled { opacity: .5; cursor: not-allowed; }
|
|
83
|
+
[data-voice-recorder-state="recording"] .studio-recorder-button { background: var(--color-danger-fill, var(--color-danger, #dc2626)); }
|
|
84
|
+
.studio-recorder-timer { font-variant-numeric: tabular-nums; font-size: .875rem; color: var(--color-text-muted, inherit); }
|
|
85
|
+
.studio-recorder-status { min-height: 2.5rem; max-width: 24rem; margin: 0; color: var(--color-text-body, inherit); }
|
|
86
|
+
.studio-recorder-failed { display: none; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
|
|
87
|
+
[data-studio-controller-failed~="voice-recorder"] .studio-recorder-failed { display: block; }
|
|
88
|
+
.studio-mic-failed { display: none; margin: 0; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
|
|
89
|
+
[data-studio-controller-failed~="microphone-chip"] .studio-mic-failed { display: block; }
|
|
90
|
+
@media (prefers-reduced-motion: reduce) {
|
|
91
|
+
[data-microphone-live="true"] .studio-mic-dot { transform: none; transition: none; }
|
|
92
|
+
.studio-recorder-button { transition: none; }
|
|
93
|
+
}
|
|
94
|
+
</style>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -303,8 +303,10 @@ module Studio
|
|
|
303
303
|
# User#inspect: POST /profile/voice carries voice[embeddings], and the user
|
|
304
304
|
# holds voice_embedding (Voicebox logged vectors until it filtered them).
|
|
305
305
|
# `(\A|\.)` also masks the JSON param wrapper's copy (profile.voice.embeddings).
|
|
306
|
+
# POST /voice/takes carries voice[embedding] (one take's vector) and
|
|
307
|
+
# voice[audio] (the take itself); both are masked the same way.
|
|
306
308
|
initializer "studio.voice_profile.filter_parameters" do |app|
|
|
307
|
-
app.config.filter_parameters += [/(\A|\.)voice\.embeddings\z/, /\Avoice_embedding\z/]
|
|
309
|
+
app.config.filter_parameters += [/(\A|\.)voice\.(embeddings|embedding|audio)\z/, /\Avoice_embedding\z/]
|
|
308
310
|
end
|
|
309
311
|
|
|
310
312
|
initializer "studio.footer_only_eager_load" do
|
data/lib/studio/version.rb
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The words on the engine's voice capture layer: the microphone chip and its
|
|
4
|
+
# help dialog (studio/voice/_microphone_chip, _microphone_help), and the record
|
|
5
|
+
# button (studio/voice/_recorder). Ported from Voicebox's MicrophoneHelper and
|
|
6
|
+
# RecordingsHelper; the contract is docs/VOICE_CAPTURE.md.
|
|
7
|
+
#
|
|
8
|
+
# The chip names the microphone the page records from, so a switched-off
|
|
9
|
+
# headset Chrome picked on its own is plain to see, and lets the person pick
|
|
10
|
+
# another. A host passes `copy:` to either partial to change any line; its
|
|
11
|
+
# keys merge over these, one level deep per section.
|
|
12
|
+
module Studio
|
|
13
|
+
module VoiceCapture
|
|
14
|
+
# Where the chip remembers the pick (localStorage), unless a partial is given
|
|
15
|
+
# storage_key:. Mirrors STORAGE_KEY in app/javascript/studio/microphone.js.
|
|
16
|
+
STORAGE_KEY = "studio.microphone"
|
|
17
|
+
|
|
18
|
+
MICROPHONE_COPY = {
|
|
19
|
+
# Before the person allows the microphone the browser gives no names.
|
|
20
|
+
"prompt" => { "chip" => "Microphone: tap Record to allow",
|
|
21
|
+
"note" => "Tap Record once and allow the microphone. Then you can pick one here." },
|
|
22
|
+
# The microphone icon says what the name is, so the name gets the whole
|
|
23
|
+
# width at 390px; a screen reader hears "aria" in full.
|
|
24
|
+
"ready" => { "chip" => "%{microphone}", "aria" => "Microphone: %{microphone}. Tap to pick another.",
|
|
25
|
+
"note" => "Pick the microphone to record with. Your next take uses it." },
|
|
26
|
+
"denied" => { "chip" => "Microphone not allowed",
|
|
27
|
+
"note" => "This site isn't allowed to use the microphone. Tap the icon beside the web address, " \
|
|
28
|
+
"set Microphone to Allow, then reload this page." },
|
|
29
|
+
"unsupported" => { "chip" => "No microphone here",
|
|
30
|
+
"note" => "This browser can't list microphones. Try Safari or Chrome." },
|
|
31
|
+
"fell_back" => "That microphone isn't connected, so I'm using %{microphone}.",
|
|
32
|
+
"empty" => "No microphones found. Plug one in and it shows up here.",
|
|
33
|
+
"in_use" => "In use",
|
|
34
|
+
# Shown only when the chip's controller failed to load.
|
|
35
|
+
"unavailable" => "Picking a microphone didn't load. Reload to try again.",
|
|
36
|
+
# The help dialog. Switching happens on the page, by device id, so the
|
|
37
|
+
# dialog never sends anyone to Chrome to switch: Chrome's settings are
|
|
38
|
+
# where the microphone is ALLOWED, and where a person checks it when a
|
|
39
|
+
# take stays silent.
|
|
40
|
+
"help" => {
|
|
41
|
+
"link" => "Help with microphones",
|
|
42
|
+
"title_chrome" => "How to change your microphone in Chrome",
|
|
43
|
+
"title_other" => "How to change your microphone",
|
|
44
|
+
"steps_chrome" => [
|
|
45
|
+
"Click the icon left of the address.",
|
|
46
|
+
"Turn Microphone on.",
|
|
47
|
+
"Make sure your microphone is allowed.",
|
|
48
|
+
"Reload the page."
|
|
49
|
+
],
|
|
50
|
+
"other" => "Allow the microphone when your browser asks, or in its settings for this site, " \
|
|
51
|
+
"then pick your microphone here and reload.",
|
|
52
|
+
"unnamed" => "the microphone",
|
|
53
|
+
"switched" => "Switched to %{microphone} ✓",
|
|
54
|
+
"switched_tip_chrome" => "If Chrome asks or it stays silent, here's how to check Chrome's microphone.",
|
|
55
|
+
"switched_tip_other" => "If your browser asks or it stays silent, here's how to check its microphone.",
|
|
56
|
+
"silent" => "I couldn't hear anything from %{microphone}.",
|
|
57
|
+
"silent_tip" => "Pick another microphone in the list, or check that the browser may use it:",
|
|
58
|
+
"denied" => "This site isn't allowed to use the microphone yet.",
|
|
59
|
+
"denied_tip" => "Allow it, then reload:",
|
|
60
|
+
"close" => "Close"
|
|
61
|
+
}
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
# The record button's words per state. Mirrors COPY in
|
|
65
|
+
# app/javascript/studio/voice_recorder.js, which fills any state a page
|
|
66
|
+
# leaves out; the partial renders the idle words so the button reads right
|
|
67
|
+
# before the controller connects.
|
|
68
|
+
RECORDER_COPY = {
|
|
69
|
+
"idle" => { "button" => "Record", "status" => "" },
|
|
70
|
+
"recording" => { "button" => "Stop", "status" => "Listening..." },
|
|
71
|
+
"unavailable" => "Recording didn't load on this page. Reload to try again."
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# The copy with a host's overrides merged in: a Hash section merges key by
|
|
77
|
+
# key, anything else replaces.
|
|
78
|
+
def microphone_copy(overrides = nil)
|
|
79
|
+
merge(MICROPHONE_COPY, overrides)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def recorder_copy(overrides = nil)
|
|
83
|
+
merge(RECORDER_COPY, overrides)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def merge(base, overrides)
|
|
87
|
+
return base if overrides.nil? || overrides.empty?
|
|
88
|
+
|
|
89
|
+
overrides.to_h.each_with_object(base.dup) do |(key, value), merged|
|
|
90
|
+
key = key.to_s
|
|
91
|
+
merged[key] = merged[key].is_a?(Hash) && value.is_a?(Hash) ? merged[key].merge(value.transform_keys(&:to_s)) : value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|